commit bbeeb633de302db1c438dfdd00502f5e95d7cfa7 Author: Marvin Wade Date: Sat Sep 12 17:58:54 2026 +0000 Import Site Gateway app and clean up for standalone release diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bfc5320 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +.git +.DS_Store +data + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a4f073c --- /dev/null +++ b/.env.example @@ -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= diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000..b15a1c8 --- /dev/null +++ b/.github/workflows/container.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45b3441 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +.env +.DS_Store +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..76bdfa4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1942094 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e32b27 --- /dev/null +++ b/README.md @@ -0,0 +1,296 @@ +
+ Site Gateway icon +

Site Gateway

+

Host. Proxy. Secure.

+

A friendly, self-hosted gateway for websites, applications, domains, and automatic HTTPS.

+

+ Container build + Docker + Architectures + Caddy + Public alpha +

+

+ Quick start · + Domains & TLS · + Unraid · + ZimaOS · + v0.9 installation guide · + Roadmap +

+
+ +--- + +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). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..711f251 --- /dev/null +++ b/ROADMAP.md @@ -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 | diff --git a/compose.release.yaml b/compose.release.yaml new file mode 100644 index 0000000..9110334 --- /dev/null +++ b/compose.release.yaml @@ -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" diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fb89c0b --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..54b31d6 --- /dev/null +++ b/docker-entrypoint.sh @@ -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" diff --git a/package.json b/package.json new file mode 100644 index 0000000..63b5f57 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..85c2a21 --- /dev/null +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/public/app.js b/src/public/app.js new file mode 100644 index 0000000..0f0921b --- /dev/null +++ b/src/public/app.js @@ -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 = 'Access List OptionalProtect this hosted site and all of its domains.'; 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 = 'Access List OptionalProtect this route and all of its domains.'; 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 = `

Operations

Scheduled jobs

${(system.jobs || []).map(job => `
${escapeHtml(job.name)}${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; } +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)}"` : ""}>${escapeHtml(item.name)}${escapeHtml(item.message)}`).join("") : '

Everything looks good.

'; + $("#activity-list").innerHTML = data.activity.length ? data.activity.slice(0, 5).map(item => `
${item.status === "error" || item.status === "warning" ? "!" : "✓"}${escapeHtml(item.message)}${escapeHtml(formatTime(item.at))}
`).join("") : '

No recent activity.

'; +} +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 ? `` : 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() ? `` : ""; + const toggle = canManage() ? `` : ""; + return `
${iconMarkup(site)}
${menu}

${escapeHtml(site.name)}

${escapeHtml(site.domain || `Port ${site.port}`)}

${site.domain ? `

${escapeHtml(publicUrl(site))}

` : ""}

${upstream}

`; +} +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() ? `` : ""; + const toggle = canManage() ? `` : ""; + const access = proxy.accessListId ? (state.accessLists.find(item => item.id === proxy.accessListId)?.name || "Access List") : "Public · no Access List"; + return `
${iconMarkup(proxy)}
${menu}

${escapeHtml(proxy.name)}

${escapeHtml(proxy.target)}

${escapeHtml(publicUrl(proxy))}

${upstream}

${escapeHtml(access)}

`; +} + +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 => `
${escapeHtml(cert.domain)}${escapeHtml(cert.kind)} · ${escapeHtml(cert.name)} · ${escapeHtml(cert.source)}${cert.expiresAt ? `${cert.daysRemaining} days remaining` : cert.status === "mismatch" ? "Domain mismatch" : "Not detected"}${cert.expiresAt ? `Expires ${formatTime(cert.expiresAt)}` : cert.mismatch ? `Covers: ${(cert.coveredNames || []).map(escapeHtml).join(", ") || "no DNS names"}` : "No stored certificate was found"}
Status
${escapeHtml(cert.status)}
Valid from
${cert.validFrom ? escapeHtml(formatTime(cert.validFrom)) : "—"}
Issuer
${escapeHtml(cert.issuer || "—")}
Covered domains
${escapeHtml((cert.coveredNames || []).join(", ") || "—")}
Serial number
${escapeHtml(cert.serialNumber || "—")}
SHA-256 fingerprint
${escapeHtml(cert.fingerprint || "—")}
Last detected update
${cert.updatedAt ? escapeHtml(formatTime(cert.updatedAt)) : "—"}
`).join("") : '

No HTTPS domains are configured.

'; + 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 `
${escapeHtml(item.domain)}${escapeHtml(message)}Click to view diagnostics
`; + }).join("") : '

No configured domains to check.

'; +} + +function showReadinessDetails(item) { + const check = item.upstream; + const upstream = check ? `
Upstream
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"}
Last checked
${escapeHtml(formatTime(check.checkedAt))}
${check.error ? `
Failure detail
${escapeHtml(check.error)}
` : ""}` : "
Upstream
No upstream health check configured.
"; + $("#readiness-title").textContent = item.domain; + $("#readiness-detail-content").innerHTML = `
DNS
${item.dns.healthy ? `Resolved${item.dns.addresses.length ? ` · ${escapeHtml(item.dns.addresses.join(", "))}` : ""}` : `Failed${item.dns.error ? ` · ${escapeHtml(item.dns.error)}` : ""}`}
Gateway ports
HTTP 80 ${item.ports.http ? "responding" : "not responding"} · HTTPS 443 ${item.ports.https === false ? "not responding" : "responding"}
TLS
${escapeHtml(item.tls.status.replaceAll("-", " "))}
${upstream}
`; + $("#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 = '' + data.hosts.map(host => ``).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`} · Checked ${escapeHtml(formatTime(new Date().toISOString()))}`; + $("#log-rows").innerHTML = entries.length ? entries.map(entry => `${escapeHtml(formatTime(entry.at))}${escapeHtml(entry.host || "—")}${escapeHtml(entry.method || "")} ${escapeHtml(entry.uri || "")}${entry.status ?? "—"}${entry.durationMs == null ? "—" : `${entry.durationMs} ms`}`).join("") : 'No matching requests have been logged yet.'; + 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 `
${escapeHtml(item.message)}${escapeHtml(eventCategory)} · ${escapeHtml(formatTime(item.at))}
`; }).join("") : '
No matching gateway eventsTry a different severity or category filter.
'; +} + +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]) => `
${count}${label}
`).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" ? `` : ``; + const statusToggle = user.status === "archived" ? "" : ``; + const deleteAction = !isSelf ? `` : ""; + return `
${escapeHtml(initials(user.displayName))}
${escapeHtml(user.status)}

${escapeHtml(user.displayName)}${isSelf ? ' You' : ""}

${escapeHtml(user.username)}

${roleLabel}${user.lastLoginAt ? `Last login ${escapeHtml(formatTime(user.lastLoginAt))}` : "Never signed in"}
`; + }).join("") : '

No users found.

'; + 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 = ''; 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 = ''; 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", ''); }); } +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 = '

Enter at least two characters to search.

'; $("#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 = '

Enter at least two characters to search.

'; return; } + $("#icon-results").innerHTML = '

Searching…

'; + iconSearchTimer = setTimeout(async () => { + try { + const results = await api(`/api/icons/search?q=${encodeURIComponent(query)}`); + $("#icon-results").innerHTML = results.length ? results.map(icon => ``).join("") : '

No matching icons found.

'; + } 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 = `

Administration

${escapeHtml(title)}

${escapeHtml(message)}

`; 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 = `

Operations

Scheduled jobs

${(system.jobs || []).map(job => `
${escapeHtml(job.name)}${job.enabled ? `Active · ${escapeHtml(job.schedule)}` : "Disabled"}
`).join("")}
`; } +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); diff --git a/src/public/features.js b/src/public/features.js new file mode 100644 index 0000000..7a6a4de --- /dev/null +++ b/src/public/features.js @@ -0,0 +1,164 @@ +function extendedEscape(value) { return escapeHtml(value); } +function featureIcon(item, fallback) { return item.icon ? `` : fallback; } +const backupDialogTextFix = new MutationObserver(() => { const dialog = document.querySelector("#create-backup-dialog"); if (dialog) dialog.querySelectorAll("p,small").forEach(node => { if (node.textContent.includes("Hosted Site files")) node.textContent = node.textContent.replaceAll("Hosted Site files", "uploaded hosted-site files"); }); }); +backupDialogTextFix.observe(document.body, { childList:true, subtree:true }); + +function renderRedirects() { + const list = document.querySelector("#redirect-list"), empty = document.querySelector("#redirect-empty"); + // Keep the existing cards or empty state mounted while the shared refresh is pending. + // The completed response is the only point at which this view should be replaced. + if (!state.loaded) return; + empty.classList.toggle("hidden", !state.loaded || state.redirects.length > 0); + list.innerHTML = state.redirects.map(item => `
${featureIcon(item,"RD")}

${extendedEscape(item.name)}

${extendedEscape(item.domain)}

→ ${extendedEscape(item.target)}${item.preservePath ? " · preserves path" : ""}

`).join(""); +} + +function renderAccessLists() { + const list = document.querySelector("#access-list"); + list.classList.toggle("loading", !state.loaded); + list.innerHTML = state.accessLists.length ? `
${state.accessLists.map(item => { const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const loginCount = item.credentials?.length || 0; return `
${featureIcon(item, "AL")}

${extendedEscape(item.name)}

${item.enabled === false ? "Protection disabled" : "Protection available"}

${item.networks?.length || 0}Allowed network${item.networks?.length === 1 ? "" : "s"}${loginCount}Login${loginCount === 1 ? "" : "s"}${assigned.length}Assigned host${assigned.length === 1 ? "" : "s"}
`; }).join("")}
` : '

Create your first Access List

Protect your hosts with reusable network and login rules.

'; + const options = '' + state.accessLists.filter(item => item.enabled !== false).map(item => ``).join(""); + document.querySelectorAll('select[name="accessListId"], #settings-access-list').forEach(select => { const value = select.value; select.innerHTML = options; select.value = value; }); +} + +function renderBackups() { + if (!state.settings) return; + const form = document.querySelector("#backup-settings-form"), defaults = state.settings.backups || {}; + for (const key of ["type","frequency","hour","retention"]) if (form.elements[key] && defaults[key] !== undefined) form.elements[key].value = defaults[key]; + form.elements.enabled.checked = Boolean(defaults.enabled); form.elements.includeLogs.checked = Boolean(defaults.includeLogs); form.elements.encrypt.checked = Boolean(defaults.encrypt); + document.querySelector("#backup-path").textContent = `Backups are stored in ${state.settings.backupDirectory}. Separate storage can be mounted directly at /data/backups for disk-failure protection.`; + document.querySelector("#backup-list").innerHTML = state.backups.length ? state.backups.map(item => `
${extendedEscape(item.filename)}${formatTime(item.createdAt)}
${extendedEscape(item.type)}Site Gateway ${extendedEscape(item.appVersion)}
${formatBytes(item.size)}${item.valid ? "Verified manifest" : "Unreadable manifest"}
Download
`).join("") : '

No stored backups yet.

'; +} + +function renderDefaultSettings() { + if (!state.settings) return; const form = document.querySelector("#default-site-form"), value = state.settings.defaultSite || {}; + if (!document.querySelector("#default-site-help")) { const help = document.createElement("p"); help.id = "default-site-help"; help.className = "muted"; help.textContent = "The Default Site handles unknown HTTP hostnames. HTTPS requests still require a matching host and certificate."; form.prepend(help); } + for (const key of ["mode","title","message","redirectUrl","redirectCode","customHtml"]) if (form.elements[key] && value[key] !== undefined) form.elements[key].value = value[key]; + form.elements.preservePath.checked = value.preservePath !== false; +} + +function renderHealthSettings() { + if (!state.settings) return; const form = document.querySelector("#health-settings-form"), value = state.settings.certificateHealth || {}; + for (const key of ["warningDays","criticalDays","staleMinutes"]) if (value[key] !== undefined) form.elements[key].value = value[key]; +} + +function decorateAccessAssignments() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item || card.querySelector(".access-assignment-preview")) return; const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const preview = document.createElement("p"); preview.className = "access-assignment-preview"; preview.textContent = assigned.length ? `Protects: ${assigned.map(host => host.name || host.domain).join(" · ")}` : "Not assigned to a host"; card.querySelector(".card-footer")?.before(preview); }); } +function renderAuditPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users || document.querySelector('[data-admin-panel="audit"]')) return; const tab = document.createElement("button"); tab.dataset.adminTab = "audit"; tab.textContent = "Audit log"; tabs.insertBefore(tab, tabs.children[1]); const panel = document.createElement("section"); panel.dataset.adminPanel = "audit"; panel.className = "settings-panel hidden"; panel.innerHTML = '

Configuration audit log

A history of Site Gateway configuration changes. Audit records cannot be edited or deleted.

Open this tab to load audit records.

'; users.parentElement.insertBefore(panel, users.nextElementSibling); const load = async () => { const records = await api(`/api/audit?action=${encodeURIComponent(document.querySelector("#audit-action").value)}&status=${encodeURIComponent(document.querySelector("#audit-status").value)}`); document.querySelector("#audit-list").innerHTML = records.length ? records.map(item => `
${item.status === "error" ? "!" : "✓"}${extendedEscape(item.action)}${extendedEscape(item.actor || "System")} · ${extendedEscape(item.status === "error" ? "Failed" : "Success")} · ${extendedEscape(formatTime(item.created_at))}
`).join("") : '

No matching audit records.

'; }; let timer; tab.addEventListener("click", async () => { document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === tab)); document.querySelectorAll("[data-admin-panel]").forEach(item => item.classList.toggle("hidden", item !== panel)); await load(); }); panel.querySelector("#audit-action").addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 300); }); panel.querySelector("#audit-status").addEventListener("change", load); } +function hideRestrictedControls() { if (state.user?.role !== "viewer") return; document.querySelectorAll("#access-list .menu-wrap, #redirect-list .menu-wrap, #access-list [data-access-action=toggle], #redirect-list [data-redirect-action=toggle], .create-trigger, #open-create, #create-backup, #import-backup").forEach(element => { element.classList.add("hidden"); element.setAttribute("aria-hidden", "true"); }); } +function renderRetentionPanel() { if (state.user?.role !== "administrator") return; const tabs = document.querySelector(".admin-tabs"), users = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !users) return; let tab = tabs.querySelector('[data-admin-tab="retention"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "retention"; tab.textContent = "Logs & retention"; tabs.append(tab); } let panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "retention"; panel.className = "settings-panel hidden"; users.parentElement.append(panel); } const policy = state.settings?.logsRetention || { accessDays:30, activityDays:90, auditDays:365, certificateDays:365, securityDays:365, pruningEnabled:false }; panel.innerHTML = `

Logs & retention

Choose how long Site Gateway keeps operational and administrative records. Pruning is disabled until you enable it.

`; panel.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { const updated = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ logsRetention:{ accessDays:Number(form.get("accessDays")), activityDays:Number(form.get("activityDays")), auditDays:Number(form.get("auditDays")), certificateDays:Number(form.get("certificateDays")), securityDays:Number(form.get("securityDays")), pruningEnabled:form.has("pruningEnabled") } }) }); state.settings = updated; toast("Log retention policy saved."); } catch (error) { toast(error.message); } }); } +window.renderExtendedViews = function () { renderRedirects(); renderAccessLists(); decorateAccessAssignments(); decorateAccessGroups(); decorateAccessToggles(); renderBackups(); renderDefaultSettings(); renderHealthSettings(); renderGroups(); decorateGroupCards(); renderAuditPanel(); renderRetentionPanel(); const retentionPanel = document.querySelector('[data-admin-panel="retention"]'); const retentionHeading = retentionPanel?.querySelector('.panel-heading > div'); if (retentionHeading && !retentionHeading.querySelector('.retention-eyebrow')) retentionHeading.insertAdjacentHTML("afterbegin", '

AUTOMATIC LOG PRUNING

'); const retentionActions = retentionPanel?.querySelector('.retention-actions'); if (retentionPanel && !retentionActions) { retentionPanel.querySelector('.panel-heading')?.insertAdjacentHTML('beforeend', '
'); retentionPanel.querySelector('[data-retention-action="prune"]')?.addEventListener('click', () => toast('Pruning will run when automatic pruning is enabled and the policy is saved.')); retentionPanel.querySelector('[data-retention-action="download"]')?.addEventListener('click', () => toast('Log download is not available yet.')); } normalizeAdminTabOrder(); hideRestrictedControls(); }; + +for (let hour = 0; hour < 24; hour++) document.querySelector('#backup-settings-form [name="hour"]').insertAdjacentHTML("beforeend", ``); + +document.querySelector("#redirect-form").addEventListener("submit", async event => { + event.preventDefault(); const form = new FormData(event.target), body = Object.fromEntries(form); body.domains = String(form.get("domainsText") || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); body.preservePath = form.has("preservePath"); document.querySelector("#redirect-error").textContent = ""; + try { const id = event.target.dataset.editing; await api(id ? `/api/redirects/${id}` : "/api/redirects", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#redirect-dialog").close(); await refresh(); toast(`Redirect Host ${id ? "updated" : "created"} and applied.`); } catch (error) { document.querySelector("#redirect-error").textContent = error.message; } +}); +document.querySelector("#redirect-list")?.addEventListener("click", event => { if (!event.target.closest("[data-redirect-action=edit]")) return; const row = event.target.closest("[data-redirect-id]"); const item = state.redirects.find(value => value.id === row?.dataset.redirectId); if (!item) return; setTimeout(() => { const form = document.querySelector("#redirect-form"); if (form.elements.domainsText) form.elements.domainsText.value = (item.domains || []).filter(domain => domain !== item.domain).join("\n"); if (form.elements.accessListId) form.elements.accessListId.value = item.accessListId || ""; }, 0); }, true); +if (!document.querySelector("#redirect-form [name=domainsText]")) { const source = document.querySelector("#redirect-form [name=domain]"); const label = document.createElement("label"); label.innerHTML = 'Additional source domains OptionalOne alias per line. All source domains use this redirect destination.'; source.closest("label").after(label); } +if (!document.querySelector("#redirect-form [name=accessListId]")) { const tls = document.querySelector("#redirect-form [name=tls]")?.closest("label"); if (tls) { const label = document.createElement("label"); label.innerHTML = 'Access List OptionalProtect this redirect and all of its source domains.'; tls.before(label); } } + +document.querySelector("#access-form").addEventListener("submit", async event => { + event.preventDefault(); const form = new FormData(event.target), body = { name:form.get("name"), networks:form.get("networks"), deniedNetworks:form.get("deniedNetworks") }; const editorRows = [...document.querySelectorAll("#access-credential-editor .credential-row")]; body.credentials = editorRows.map(row => ({ username: row.querySelector("[name=credentialUsername]").value.trim(), password: row.querySelector("[name=credentialPassword]").value })).filter(entry => entry.username); if (!event.target.dataset.editing) body.groups = [...document.querySelectorAll("#access-create-groups [data-create-group]:checked")].map(input => input.dataset.createGroup); document.querySelector("#access-error").textContent = ""; + try { const id = event.target.dataset.editing; await api(id ? `/api/access-lists/${id}` : "/api/access-lists", { method:id ? "PATCH" : "POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) }); delete event.target.dataset.editing; document.querySelector("#access-dialog").close(); await refresh(); toast(`Access List ${id ? "updated" : "created"}.`); } catch (error) { document.querySelector("#access-error").textContent = error.message; } +}); + +function renderCredentialEditor(credentials = []) { const editor = document.querySelector("#access-credential-editor"); editor.classList.remove("hidden"); editor.innerHTML = `
Login accounts

Add a username and password. When editing an existing user, leave the password blank to keep it unchanged.

${credentials.map(credential => `
`).join("")}`; document.querySelector("#add-access-credential").addEventListener("click", () => { const row = document.createElement("div"); row.className = "credential-row"; row.innerHTML = ''; editor.append(row); row.querySelector(".remove-credential").addEventListener("click", () => row.remove()); }); editor.querySelectorAll(".remove-credential").forEach(button => button.addEventListener("click", () => button.closest(".credential-row").remove())); } +window.renderCredentialEditor = renderCredentialEditor; + +document.querySelector("#redirect-list").addEventListener("click", async event => { + const button = event.target.closest("[data-redirect-action]"), card = button?.closest("[data-redirect-id]"); if (!button || !card) return; const item = state.redirects.find(value => value.id === card.dataset.redirectId); if (!item) return; + try { if (button.dataset.redirectAction === "edit") { const form = document.querySelector("#redirect-form"); form.reset(); form.dataset.editing = item.id; for (const key of ["name","domain","target","code","tls"]) form.elements[key].value = item[key] || ""; form.elements.preservePath.checked = item.preservePath !== false; document.querySelector("#redirect-dialog").showModal(); return; } if (button.dataset.redirectAction === "delete") { if (!confirm(`Delete redirect “${item.name}”?`)) return; await api(`/api/redirects/${item.id}`, { method:"DELETE" }); } else await api(`/api/redirects/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:!item.enabled }) }); await refresh(); toast("Redirect Host updated."); } catch (error) { toast(error.message); } +}); + +function themedAccessDialog(title, copy, confirmLabel = "Delete", danger = false) { let dialog = document.querySelector("#access-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "access-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Access Lists

${extendedEscape(title)}

${copy}

${confirmLabel ? `` : ""}
`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); } +document.querySelector("#access-list").addEventListener("click", async event => { + if (event.target.closest(".menu-button")) { const card = event.target.closest("[data-access-id]"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll("#access-list .menu-open").forEach(item => item.classList.remove("menu-open")); card.classList.toggle("menu-open", opening); card.querySelector(".menu-button")?.setAttribute("aria-expanded", String(opening)); return; } + const button = event.target.closest("[data-access-action]"), row = button?.closest("[data-access-id]"); if (!button || !row) return; const item = state.accessLists.find(value => value.id === row.dataset.accessId); if (!item) return; + try { const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); if (button.dataset.accessAction === "edit") { const latest = (await api("/api/access-lists")).find(value => value.id === item.id) || item; state.accessLists = state.accessLists.map(value => value.id === item.id ? latest : value); const form = document.querySelector("#access-form"); form.reset(); form.querySelector(".access-create-guidance")?.remove(); form.querySelector("#access-create-groups")?.remove(); form.dataset.editing = latest.id; form.elements.name.value = latest.name; form.elements.networks.value = (latest.networks || []).join("\n"); form.elements.deniedNetworks.value = (latest.deniedNetworks || []).join("\n"); const summary = document.querySelector("#access-assignment-summary"); summary.classList.remove("hidden"); summary.innerHTML = ""; renderCredentialEditor(latest.credentials || []); renderAssignmentEditor(latest.id); ensureAssignmentSearch(); renderAccessGroupSelector(latest.id); document.querySelector("#access-dialog").showModal(); return; } if (button.dataset.accessAction === "icon") { row.classList.remove("menu-open"); openIconPicker("access", item.id); return; } if (button.dataset.accessAction === "assignments") { row.classList.remove("menu-open"); await themedAccessDialog(`Assigned hosts · ${item.name}`, assigned.length ? `${assigned.length} protected host${assigned.length === 1 ? "" : "s"}
${assigned.map(host => `${extendedEscape(host.name || host.domain)}${extendedEscape(host.domain || "No domain")}`).join("")}
` : "This Access List is not assigned to any hosts.", ""); return; } if (button.dataset.accessAction === "delete") { const copy = assigned.length ? `This Access List protects ${assigned.length} active host${assigned.length === 1 ? "" : "s"}. Delete it anyway?` : `Delete Access List “${extendedEscape(item.name)}”?`; if (!(await themedAccessDialog("Delete Access List?", copy, "Delete", true))) return; await api(`/api/access-lists/${item.id}`, { method:"DELETE" }); } else { if (item.enabled !== false && assigned.length && !(await themedAccessDialog("Disable Access List?", `Disabling this Access List will remove protection from ${assigned.length} active host${assigned.length === 1 ? "" : "s"}. Continue?`, "Disable", true))) return; await api(`/api/access-lists/${item.id}`, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:item.enabled === false }) }); } await refresh(); toast("Access List updated."); } catch (error) { toast(error.message); } +}); +document.querySelector("#access-list").addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); const item = state.accessLists.find(value => value.id === row?.dataset.accessId); if (!item) return; setTimeout(() => { const assigned = [...state.proxies, ...state.sites, ...state.redirects].filter(host => host.accessListId === item.id); const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; summary.innerHTML = assigned.length ? `Assigned hosts (${assigned.length})
${assigned.map(host => `${extendedEscape(host.name || host.domain)}${extendedEscape(host.domain || "No domain")}`).join("")}
` : `Assigned hostsThis Access List is not assigned to a host yet.`; }, 0); }); + +document.querySelector(".admin-tabs").addEventListener("click", event => { const button = event.target.closest("[data-admin-tab]"); if (!button) return; document.querySelectorAll("[data-admin-tab]").forEach(item => item.classList.toggle("tab-active", item === button)); document.querySelectorAll("[data-admin-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.adminPanel !== button.dataset.adminTab)); document.querySelector("#open-create").classList.toggle("hidden", button.dataset.adminTab !== "users"); }); +document.querySelector(".admin-tabs").addEventListener("click", event => { const button = event.target.closest("[data-admin-tab]"); if (!button) return; state.adminTab = button.dataset.adminTab; history.replaceState(null, "", `${location.pathname}${location.search}#administration/${state.adminTab}`); }); +if (state.adminTab && state.view === "administration") document.querySelector(`[data-admin-tab="${state.adminTab}"]`)?.click(); + +document.querySelector("#default-site-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), value = Object.fromEntries(form); value.preservePath = form.has("preservePath"); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({defaultSite:value}) }); toast("Default Site validated and applied."); } catch (error) { document.querySelector("#default-error").textContent = error.message; } }); + +document.querySelector("#backup-settings-form [name=type]")?.addEventListener("change", event => { document.querySelector("#backup-type-help").textContent = event.target.value === "complete" ? "Complete backups include configuration, uploaded Hosted Site files, icons, default-site assets, and certificate storage. Verify the file count after creation." : "Configuration-only backups include settings and metadata, but not uploaded Hosted Site files."; }); document.querySelector("#backup-settings-form")?.insertAdjacentHTML("beforeend", '

'); document.querySelector("#backup-settings-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), backups = Object.fromEntries(form); delete backups.backupPassword; backups.enabled = form.has("enabled"); backups.includeLogs = form.has("includeLogs"); backups.encrypt = form.has("encrypt"); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({backups}) }); const scheduleStatus = document.querySelector("#backup-schedule-status"); scheduleStatus.className = `inline-status ${backups.enabled ? "status-success" : "status-warning"}`; scheduleStatus.textContent = backups.enabled ? `Scheduled backups enabled · ${backups.frequency} · ${backups.type === "complete" ? "complete backups" : "configuration backups"}.` : "Scheduled backups disabled. Your saved schedule remains available if you enable it later."; } catch (error) { toast(error.message); } }); +document.querySelector("#health-settings-form").addEventListener("submit", async event => { event.preventDefault(); const certificateHealth = Object.fromEntries(new FormData(event.target)); try { state.settings = await api("/api/settings", { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({certificateHealth}) }); renderHealthSettings(); toast("Certificate health thresholds saved."); } catch (error) { toast(error.message); } }); +const restoreDefaultsButton = document.querySelector("#restore-defaults"); restoreDefaultsButton.insertAdjacentHTML("beforebegin", '

'); const restoreUsername = document.querySelector("#restore-admin-username"), restorePassword = document.querySelector("#restore-admin-password"), restoreConfirmation = document.querySelector("#restore-confirmation"), restoreInlineError = document.querySelector("#restore-defaults-error"); restoreUsername.addEventListener("blur", async () => { if (!restoreUsername.value.trim()) { restoreInlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:restoreUsername.value.trim()})}); restoreInlineError.textContent = identity.valid ? "" : "That administrator username was not found."; } catch (error) { restoreInlineError.textContent = error.message; } }); restorePassword.addEventListener("blur", async () => { if (!restorePassword.value) { restoreInlineError.textContent = "Enter the administrator password."; return; } if (!restoreUsername.value.trim()) { restoreInlineError.textContent = "Enter the administrator username first."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:restoreUsername.value.trim(),password:restorePassword.value})}); restoreInlineError.textContent = ""; } catch (error) { restoreInlineError.textContent = "The password is incorrect for the entered administrator."; } }); restoreConfirmation.addEventListener("blur", () => { if (restoreConfirmation.value && restoreConfirmation.value.trim() !== "RESTORE DEFAULT") restoreInlineError.textContent = "Type RESTORE DEFAULT exactly to continue."; }); restoreDefaultsButton.addEventListener("click", async event => { event.preventDefault(); const username = document.querySelector("#restore-admin-username").value.trim(), password = document.querySelector("#restore-admin-password").value, confirmation = document.querySelector("#restore-confirmation").value.trim(); const inlineError = document.querySelector("#restore-defaults-error"); inlineError.textContent = ""; if (!username) { inlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username})}); if (!identity.valid) { inlineError.textContent = "That administrator username was not found."; return; } } catch (error) { inlineError.textContent = error.message; return; } if (!password) { inlineError.textContent = "Enter the administrator password."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password})}); } catch (error) { inlineError.textContent = "The password is incorrect for the entered administrator."; return; } if (confirmation !== "RESTORE DEFAULT") { inlineError.textContent = "Type RESTORE DEFAULT exactly to continue."; return; } let dialog = document.querySelector("#restore-defaults-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "restore-defaults-confirm-dialog"; dialog.innerHTML = '

Gateway preferences

Confirm restore defaults

This restores the default site behavior, backup scheduling, certificate thresholds, and interface preferences. Users, routes, hosted files, certificates, logs, backups, and Access Lists will remain unchanged.

'; document.body.append(dialog); } dialog.querySelector('[name="yes"]').value = ""; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once:true })); if (result !== "confirm" || dialog.querySelector('[name="yes"]').value.trim().toUpperCase() !== "YES") { dialog.querySelector('[name="yes"]').value = ""; document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; return; } try { state.settings = await api("/api/settings/reset-defaults", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password,confirmation})}); dialog.querySelector(".dialog-card").innerHTML = '

Gateway preferences

Defaults restored

The gateway preferences were restored successfully. Your data and routes were preserved.

'; state.settings = await api("/api/settings"); renderDefaultSettings(); renderBackups(); document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; dialog.showModal(); setTimeout(() => dialog.close(), 900); } catch (error) { dialog.querySelector("[data-restore-error]").textContent = error.message; if (!dialog.open) dialog.showModal(); } }); +const factoryResetForm = document.querySelector("#factory-reset-form"), factoryUsername = factoryResetForm.elements.username, factoryPassword = factoryResetForm.elements.password, factoryConfirmation = factoryResetForm.elements.confirmation, factoryInlineError = document.querySelector("#factory-reset-error"); factoryUsername.addEventListener("blur", async () => { if (!factoryUsername.value.trim()) { factoryInlineError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:factoryUsername.value.trim()})}); factoryInlineError.textContent = identity.valid ? "" : "That administrator username was not found."; } catch (error) { factoryInlineError.textContent = error.message; } }); factoryPassword.addEventListener("blur", async () => { if (!factoryPassword.value) { factoryInlineError.textContent = "Enter the administrator password."; return; } if (!factoryUsername.value.trim()) { factoryInlineError.textContent = "Enter the administrator username first."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:factoryUsername.value.trim(),password:factoryPassword.value})}); factoryInlineError.textContent = ""; } catch (error) { factoryInlineError.textContent = "The password is incorrect for the entered administrator."; } }); factoryConfirmation.addEventListener("blur", () => { if (factoryConfirmation.value && factoryConfirmation.value.trim() !== "FACTORY RESET") factoryInlineError.textContent = "Type FACTORY RESET exactly to continue."; }); document.querySelector("#factory-reset-form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target), confirmation = String(form.get("confirmation") || ""), resetError = document.querySelector("#factory-reset-error"), username = String(form.get("username") || "").trim(), password = String(form.get("password") || ""); resetError.textContent = ""; if (!username) { resetError.textContent = "Enter the administrator username."; return; } try { const identity = await api("/api/settings/verify-username", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username})}); if (!identity.valid) { resetError.textContent = "That administrator username was not found."; return; } } catch (error) { resetError.textContent = error.message; return; } if (!password) { resetError.textContent = "Enter the administrator password."; return; } try { await api("/api/settings/verify-admin", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username,password})}); } catch (error) { resetError.textContent = "The password is incorrect for the entered administrator."; return; } if (confirmation !== "FACTORY RESET") { resetError.textContent = "Type FACTORY RESET exactly to continue."; return; } let dialog = document.querySelector("#factory-reset-confirm-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "factory-reset-confirm-dialog"; dialog.innerHTML = '

Permanent action

Confirm factory reset

This will permanently delete all Site Gateway data under /data, including hosted files, routes, users, groups, Access Lists, certificates, logs, backups, and settings. The container will restart and return to the first-install setup screen.

After restart, open the management URL again and use the original installation credentials to begin setup.

'; document.body.append(dialog); } dialog.querySelector('[name="yes"]').value = ""; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once:true })); if (result !== "confirm" || dialog.querySelector('[name="yes"]').value.trim().toUpperCase() !== "YES") { dialog.querySelector('[name="yes"]').value = ""; factoryResetForm.reset(); factoryInlineError.textContent = ""; return; } try { await api("/api/factory-reset", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(Object.fromEntries(form))}); dialog.querySelector(".dialog-card").innerHTML = '

Permanent action

Factory reset in progress

Site Gateway is deleting its data and restarting. Keep this window open. The first-install setup screen will open automatically when the container is ready.

Restarting in 10 seconds…

'; dialog.showModal(); let seconds = 10; const timer = setInterval(() => { seconds--; const counter = dialog.querySelector("[data-reset-countdown]"); if (counter) counter.textContent = String(seconds); if (seconds <= 0) { clearInterval(timer); if (counter) counter.textContent = "Opening setup…"; dialog.close(); const openSetup = () => { window.location.href = "/"; }; (async () => { for (let attempt = 0; attempt < 30; attempt++) { try { const response = await fetch("/api/session", { cache: "no-store" }); if (response.ok) { openSetup(); return; } } catch {} await new Promise(resolve => setTimeout(resolve, 1000)); } openSetup(); })(); window.setTimeout(openSetup, 5000); } }, 1000); } catch (error) { document.querySelector("#factory-reset-error").textContent = error.message; } }); + +document.querySelector("#create-backup").addEventListener("click", async () => { const form = new FormData(document.querySelector("#backup-settings-form")); try { const result = await api("/api/backups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({type:form.get("type"),includeLogs:form.has("includeLogs"),password:form.get("backupPassword")}) }); state.backups = await api("/api/backups"); renderBackups(); location.href = `/api/backups/${encodeURIComponent(result.filename)}/download`; toast("Backup created. Download starting."); } catch (error) { toast(error.message); } }); +document.querySelector("#import-backup").addEventListener("click", () => document.querySelector("#backup-upload").click()); +async function themedConfirm(title, message, actionLabel = "Continue") { let dialog = document.querySelector("#backup-action-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "backup-action-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Backup & restore

${title}

${message}

`; dialog.showModal(); return new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once:true })); } + +document.querySelector("#backup-upload").addEventListener("change", async event => { const file = event.target.files[0]; if (!file) return; const data = new FormData(), password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; data.append("backup", file); data.append("password", password); try { await api("/api/backups/import", { method:"POST", body:data }); state.backups = await api("/api/backups"); renderBackups(); toast("Backup imported. Review it before restoring."); } catch (error) { toast(error.message); } finally { event.target.value = ""; } }); + +document.querySelector("#backup-list").addEventListener("click", async event => { const button = event.target.closest("[data-backup-action]"), row = button?.closest("[data-backup]"); if (!button || !row) return; const filename = row.dataset.backup; try { if (button.dataset.backupAction === "delete") { if (!await themedConfirm("Delete backup?", `This permanently removes ${filename}. It cannot be restored unless you have another copy.`, "Delete backup")) return; await api(`/api/backups/${encodeURIComponent(filename)}`, {method:"DELETE"}); } else { if (!await themedConfirm("Restore this backup?", "Current data will be replaced after a safety backup is created. Site Gateway validates the archive and can roll back if restoration fails.", "Restore backup")) return; const password = document.querySelector('#backup-settings-form [name="backupPassword"]').value; await api(`/api/backups/${encodeURIComponent(filename)}/restore`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password})}); await refresh(); } state.backups = await api("/api/backups"); renderBackups(); toast(button.dataset.backupAction === "delete" ? "Backup deleted." : "Backup restored."); } catch (error) { toast(error.message); } }); +const restoreButton = document.querySelector("#restore-defaults"), restoreCredentialsBlock = document.querySelector(".danger-credentials"); if (restoreButton && restoreCredentialsBlock && !restoreButton.closest(".restore-form")) { const restoreForm = document.createElement("form"); restoreForm.className = "danger-form restore-form"; restoreCredentialsBlock.replaceWith(restoreForm); restoreForm.append(restoreCredentialsBlock, restoreButton); } const restoreCancel = document.createElement("button"); restoreCancel.type = "button"; restoreCancel.className = "button secondary"; restoreCancel.textContent = "Cancel"; restoreCancel.id = "restore-defaults-cancel"; const restoreActions = document.createElement("div"); restoreActions.className = "danger-actions"; restoreButton.parentNode.insertBefore(restoreActions, restoreButton); restoreActions.append(restoreCancel, restoreButton); restoreCancel.addEventListener("click", () => { document.querySelector("#restore-admin-username").value = ""; document.querySelector("#restore-admin-password").value = ""; document.querySelector("#restore-confirmation").value = ""; document.querySelector("#restore-defaults-error").textContent = ""; }); document.querySelector("#factory-reset-cancel")?.addEventListener("click", () => { document.querySelector("#factory-reset-form").reset(); document.querySelector("#factory-reset-error").textContent = ""; }); + +document.querySelectorAll("#docs-content article").forEach((article, index) => { article.id = `doc-${index}`; }); document.querySelectorAll("[data-doc-jump]").forEach(button => button.addEventListener("click", () => { const key = button.dataset.docJump; const article = [...document.querySelectorAll("#docs-content article")].find(item => item.dataset.doc.includes(key)); article?.scrollIntoView({ behavior:"smooth", block:"start" }); })); document.querySelector("#doc-search").addEventListener("input", event => { const query = event.target.value.trim().toLowerCase(), articles = [...document.querySelectorAll("#docs-content article")]; let visible = 0; for (const article of articles) { const match = !query || `${article.dataset.doc} ${article.textContent}`.toLowerCase().includes(query); article.classList.toggle("hidden", !match); if (match) visible++; } document.querySelector("#doc-empty").classList.toggle("hidden", visible > 0); }); + +document.querySelector("#proxy-dialog").addEventListener("close", () => document.querySelector("#proxy-dialog details")?.removeAttribute("open")); +document.querySelectorAll("#proxy-form, #settings-form").forEach(form => form.elements.tls.addEventListener("change", () => { const fields = form.querySelector("#custom-certificate-fields, .custom-certificate-fields"); fields?.classList.toggle("custom-certificate-visible", form.elements.tls.value === "custom"); })); +function renderAssignmentEditor(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary) return; const hosts = [...state.sites.map(host => ({ ...host, kind: "sites", label: "Hosted Site" })), ...state.proxies.map(host => ({ ...host, kind: "proxies", label: "Proxy Host" })), ...state.redirects.map(host => ({ ...host, kind: "redirects", label: "Redirect Host" }))]; summary.classList.remove("hidden"); summary.innerHTML = "Protected hosts

Select the routes this Access List should protect. Changes apply immediately.

" + (hosts.length ? hosts.map(host => "").join("") : "Create a Hosted Site, Proxy Host, or Redirect Host first.") + "
"; } +document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; const kind = checkbox.dataset.assignmentKind; try { await api("/api/" + kind + "/" + checkbox.dataset.assignmentId, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accessListId: checkbox.checked ? document.querySelector("#access-form")?.dataset.editing || "" : "" }) }); await refresh(); const current = document.querySelector("#access-form")?.dataset.editing; if (current) renderAssignmentEditor(current); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }); +document.querySelector("#access-list")?.addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); if (row) setTimeout(() => renderAssignmentEditor(row.dataset.accessId), 0); }); +function ensureAssignmentSearch() { const editor = document.querySelector("#access-assignment-summary .assignment-editor"); if (!editor || editor.querySelector(".assignment-search")) return; const label = document.createElement("label"); label.className = "assignment-search-label"; label.textContent = "Filter hosts"; const input = document.createElement("input"); input.className = "assignment-search"; input.type = "search"; input.placeholder = "Name, type, or domain"; input.setAttribute("aria-label", "Filter hosts"); label.append(input); editor.before(label); input.addEventListener("input", () => { const query = input.value.trim().toLowerCase(); editor.querySelectorAll(".assignment-option").forEach(option => { option.hidden = query && !option.textContent.toLowerCase().includes(query); }); }); } +document.querySelector("#access-list")?.addEventListener("click", () => setTimeout(ensureAssignmentSearch, 0)); +document.addEventListener("input", event => { const input = event.target.closest("#access-assignment-summary .assignment-search"); if (!input) return; const query = input.value.trim().toLowerCase(); document.querySelectorAll("#access-assignment-summary .assignment-option").forEach(option => { option.hidden = Boolean(query) && !option.textContent.toLowerCase().includes(query); }); }); +document.addEventListener("change", async event => { const checkbox = event.target.closest("#access-assignment-summary [data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; try { await api("/api/access-lists/" + accessListId + "/assignments", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ kind:checkbox.dataset.assignmentKind, hostId:checkbox.dataset.assignmentId, assigned:checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true); +document.querySelector("#access-list")?.addEventListener("change", async event => { const checkbox = event.target.closest("[data-assignment-kind]"); if (!checkbox) return; event.stopImmediatePropagation(); const accessListId = document.querySelector("#access-form")?.dataset.editing; const kind = checkbox.dataset.assignmentKind; if (!accessListId) { checkbox.checked = !checkbox.checked; toast("Open an Access List before assigning hosts."); return; } try { await api("/api/access-lists/" + accessListId + "/assignments", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ kind, hostId: checkbox.dataset.assignmentId, assigned: checkbox.checked }) }); await refresh(); renderAssignmentEditor(accessListId); ensureAssignmentSearch(); toast(checkbox.checked ? "Host added to Access List." : "Host removed from Access List."); } catch (error) { checkbox.checked = !checkbox.checked; toast(error.message); } }, true); +function renderGroups() { const tabs = document.querySelector(".admin-tabs"); const usersPanel = document.querySelector('[data-admin-panel="users"]'); if (!tabs || !usersPanel) return; let tab = tabs.querySelector('[data-admin-tab="groups"]'); if (!tab) { tab = document.createElement("button"); tab.dataset.adminTab = "groups"; tab.textContent = "Groups"; tabs.insertBefore(tab, tabs.children[1]); } let panel = document.querySelector('[data-admin-panel="groups"]'); if (!panel) { panel = document.createElement("section"); panel.dataset.adminPanel = "groups"; panel.className = "settings-panel hidden"; usersPanel.parentElement.insertBefore(panel, usersPanel.nextElementSibling); } panel.innerHTML = '

Groups

Organize users for Access List permissions.

' + (state.groups.length ? '
' + state.groups.map(group => '
GR

' + extendedEscape(group.name) + '

' + (group.members?.length || 0) + ' members

').join("") + '
' : '

No groups yet. Create one to organize users.

'); } + +function openGroupEditor(group) { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Edit group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelector('[name="name"]').value = group.name; dialog.querySelectorAll('[name="members"]').forEach(input => { input.checked = (group.memberIds || group.members || []).includes(input.value) || (group.members || []).some(value => value === state.users?.find(user => user.id === input.value)?.username); }); dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups/" + group.id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:form.get("name"), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group updated."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } +document.addEventListener("click", event => { const button = event.target.closest('[data-admin-panel="groups"] .group-card .menu-button'); if (!button) return; const card = button.closest(".group-card"); const opening = !card.classList.contains("menu-open"); document.querySelectorAll('[data-admin-panel="groups"] .group-card.menu-open').forEach(item => { item.classList.remove("menu-open"); item.querySelector(".menu-button")?.setAttribute("aria-expanded", "false"); }); card.classList.toggle("menu-open", opening); button.setAttribute("aria-expanded", String(opening)); event.preventDefault(); event.stopImmediatePropagation(); }, true); +function openNewGroupEditor() { let dialog = document.querySelector("#group-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "group-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Administration

Create group

Select Site Gateway users who should belong to this group.

' + (state.users || []).filter(user => user.status !== "disabled").map(user => '').join("") + '

'; dialog.querySelectorAll(".close-group-dialog").forEach(button => button.addEventListener("click", () => dialog.close())); dialog.querySelector("form").addEventListener("submit", async event => { event.preventDefault(); const form = new FormData(event.target); try { await api("/api/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ name:String(form.get("name") || "").trim(), members:[...event.target.querySelectorAll('[name="members"]:checked')].map(input => input.value) }) }); dialog.close(); await refresh(); toast("Group created."); } catch (error) { dialog.querySelector("[data-group-error]").textContent = error.message; } }); dialog.showModal(); } +document.addEventListener("click", async event => { if (event.target.id === "create-group") { openNewGroupEditor(); return; } const button = event.target.closest("[data-group-action]"); if (!button) return; try { const id = button.dataset.groupId; const group = state.groups.find(value => value.id === id); if (button.dataset.groupAction === "edit") { if (group) openGroupEditor(group); return; } if (button.dataset.groupAction === "icon") return; if (button.dataset.groupAction === "delete" && !confirm("Delete this group?")) return; if (button.dataset.groupAction === "delete") await api("/api/groups/" + id, { method:"DELETE" }); else await api("/api/groups/" + id, { method:"PATCH", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ enabled:button.classList.contains("toggle") ? !button.classList.contains("on") : button.textContent.trim() === "Enable" }) }); await refresh(); toast("Group updated."); } catch (error) { toast(error.message); } }); +function renderAccessGroupSelector(accessListId) { const summary = document.querySelector("#access-assignment-summary"); if (!summary || !state.groups) return; let field = summary.querySelector(".access-group-selector"); if (!field) { field = document.createElement("section"); field.className = "access-group-selector"; summary.prepend(field); } const selected = state.accessLists.find(item => item.id === accessListId)?.groups || []; field.innerHTML = "Allowed groups Optional

Members of enabled groups can sign in with their Site Gateway credentials.

" + (state.groups.length ? "
" + state.groups.map(group => "").join("") + "
" : "

No groups have been created yet.

"); } +document.addEventListener("change", async event => { const option = event.target.closest("[data-group-option]"); if (!option) return; const accessListId = document.querySelector("#access-form")?.dataset.editing; if (!accessListId) return; const groups = [...document.querySelectorAll("#access-assignment-summary [data-group-option]:checked")].map(input => input.dataset.groupOption); try { await api("/api/access-lists/" + accessListId + "/groups", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ groups }) }); const item = state.accessLists.find(value => value.id === accessListId); if (item) item.groups = groups; renderAccessLists(); decorateAccessGroups(); toast("Access List groups saved."); } catch (error) { option.checked = !option.checked; toast(error.message); } }, true); +document.addEventListener("click", event => { const button = event.target.closest("#access-list [data-access-action=toggle]"); if (button) event.stopImmediatePropagation(); }); +document.querySelector("#access-list")?.addEventListener("click", event => { if (!event.target.closest("[data-access-action=edit]")) return; const row = event.target.closest("[data-access-id]"); if (row) setTimeout(() => renderAccessGroupSelector(row.dataset.accessId), 10); }); +function decorateAccessGroups() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); if (!item) return; if (item.groups?.length && !card.querySelector(".access-group-preview")) { const names = item.groups.map(id => state.groups.find(group => group.id === id)?.name).filter(Boolean); if (names.length) { const preview = document.createElement("p"); preview.className = "access-group-preview"; preview.textContent = "Groups: " + names.join(" · "); card.querySelector(".card-footer")?.before(preview); } } if (!card.querySelector("[data-access-action=toggle]")) { const footer = card.querySelector(".card-footer"); const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", item.enabled !== false ? "Disable Access List" : "Enable Access List"); toggle.innerHTML = ""; footer?.querySelector(".card-actions")?.append(toggle); } }); } +function renderNewAccessGuidance() { const form = document.querySelector("#access-form"); if (!form || form.dataset.editing || form.querySelector(".access-create-guidance")) return; const assignmentSummary = document.querySelector("#access-assignment-summary"); if (assignmentSummary) { assignmentSummary.classList.add("hidden"); assignmentSummary.innerHTML = ""; } const guidance = document.createElement("p"); guidance.className = "access-create-guidance"; guidance.textContent = "After saving, edit this Access List to assign protected hosts. Allowed groups can be selected now or changed later."; document.querySelector("#access-credential-editor")?.after(guidance); const groupField = document.createElement("section"); groupField.id = "access-create-groups"; groupField.className = "access-create-groups"; groupField.innerHTML = `Allowed groups Optional

Members of enabled groups can sign in with their Site Gateway credentials.

${state.groups?.length ? `
${state.groups.filter(group => group.enabled !== false).map(group => ``).join("")}
` : '

No groups have been created yet. Create one under Administration → Groups.

'}`; guidance.after(groupField); } +document.querySelector("#access-list")?.addEventListener("click", () => setTimeout(renderNewAccessGuidance, 0)); +document.addEventListener("click", event => { if (event.target.closest(".create-trigger") && state.view === "access") setTimeout(renderNewAccessGuidance, 0); }); +function decorateAccessToggles() { document.querySelectorAll("#access-list [data-access-id]").forEach(card => { const item = state.accessLists.find(value => value.id === card.dataset.accessId); const footer = card.querySelector(".card-footer"); if (!footer || !item) return; card.querySelectorAll(".menu [data-access-action=toggle]").forEach(button => button.remove()); if (footer.querySelector("[data-access-action=toggle]")) return; let actions = footer.querySelector(".card-actions"); if (!actions) { actions = document.createElement("div"); actions.className = "card-actions"; footer.append(actions); } const toggle = document.createElement("button"); toggle.className = "toggle " + (item.enabled !== false ? "on" : ""); toggle.dataset.accessAction = "toggle"; toggle.setAttribute("aria-label", (item.enabled !== false ? "Disable" : "Enable") + " Access List"); toggle.innerHTML = ""; actions.append(toggle); }); } +function decorateGroupCards() { document.querySelectorAll('[data-admin-panel="groups"] .group-card').forEach(card => { const group = state.groups.find(value => value.id === card.querySelector("[data-group-action]")?.dataset.groupId); if (!group) return; const icon = card.querySelector(".site-icon"); if (icon && icon.textContent.trim() === "GR") icon.innerHTML = featureIcon(group, "GR"); const menu = card.querySelector(".menu"); if (menu && !menu.querySelector("[data-group-action=icon]")) { const button = document.createElement("button"); button.dataset.groupAction = "icon"; button.dataset.groupId = group.id; button.textContent = "Change icon"; menu.prepend(button); } }); } +document.addEventListener("click", event => { const button = event.target.closest("[data-group-action=icon]"); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); openIconPicker("groups", button.dataset.groupId); }, true); +function normalizeAdminTabOrder() { const tabs = document.querySelector(".admin-tabs"); if (!tabs) return; const order = ["users","groups","defaults","audit","backups","security","retention","danger"]; order.forEach((name, index) => { const button = tabs.querySelector(`[data-admin-tab="${name}"]`); if (button) { if (name === "retention") button.textContent = "Logs & Retention"; tabs.append(button); } }); } +document.addEventListener("click", event => { if (event.target.closest(".admin-tabs")) setTimeout(normalizeAdminTabOrder, 0); }); +const backupPasswordInput = document.querySelector('#backup-settings-form [name="backupPassword"]'); +if (backupPasswordInput) { + backupPasswordInput.placeholder = "Optional — enter a password"; + backupPasswordInput.closest(".backup-password-field")?.querySelector(".optional")?.remove(); +} +const encryptionToggle = document.querySelector('#backup-settings-form .encryption-toggle'); +if (encryptionToggle) { + encryptionToggle.className = "encryption-toggle"; + encryptionToggle.innerHTML = 'Encrypt scheduled backupsUses the container’s BACKUP_PASSWORD value. Enable only after configuring that value.'; +} +if (backupPasswordInput && !document.querySelector("#backup-password-toggle")) { + backupPasswordInput.insertAdjacentHTML("afterend", ''); + const toggle = document.querySelector("#backup-password-toggle"); + toggle.addEventListener("click", () => { const visible = backupPasswordInput.type === "text"; backupPasswordInput.type = visible ? "password" : "text"; toggle.textContent = visible ? "Show" : "Hide"; toggle.setAttribute("aria-label", visible ? "Show backup encryption password" : "Hide backup encryption password"); toggle.setAttribute("aria-pressed", String(!visible)); }); +} +document.querySelector("#create-backup")?.addEventListener("click", async event => { event.preventDefault(); event.stopImmediatePropagation(); let dialog = document.querySelector("#create-backup-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "create-backup-dialog"; document.body.append(dialog); } dialog.innerHTML = '

Backup & restore

Create a backup

Choose what to include. Site Gateway saves a copy in /data/backups and downloads a copy to your computer.

Complete backups include uploaded files, icons, certificates, and default-site assets. Configuration-only backups do not include uploaded Hosted Site files.If provided, this password is required to restore the downloaded archive.
'; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), {once:true})); if (result !== "confirm") return; const form = dialog.querySelector("form"), type = form.elements.type.value, password = form.elements.password.value; try { const created = await api("/api/backups", {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({type,includeLogs:document.querySelector('#backup-settings-form [name="includeLogs"]')?.checked === true,password})}); state.backups = await api("/api/backups"); renderBackups(); window.location.href = `/api/backups/${encodeURIComponent(created.filename)}/download`; toast("Backup created. Download starting."); } catch (error) { toast(error.message); } }, true); + +function normalizeRetentionLayout() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form || form.dataset.normalized === "true") return; const actions = form.querySelector(".dialog-actions"); const fields = [...form.children].filter(child => child !== actions); const section = document.createElement("div"); section.className = "form-section form-section-wide"; const eyebrow = document.createElement("p"); eyebrow.className = "eyebrow"; eyebrow.textContent = "Automatic Log Pruning"; section.append(eyebrow); const grid = document.createElement("div"); grid.className = "form-grid"; fields.forEach(field => grid.append(field)); section.append(grid); form.prepend(section); if (actions) form.append(actions); form.dataset.normalized = "true"; } +normalizeRetentionLayout(); +function cleanRetentionLabels() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); if (!form) return; const descriptions = { 'Access logs':'High-volume request records.', 'Gateway activity':'Operational and configuration events.', 'Audit logs':'Administrative accountability records.', 'Certificate events':'Certificate issuance and health changes.', 'Security events':'Authentication and security-related events.' }; [...form.querySelectorAll('label:not(.check-control)')].forEach(field => { const text = field.firstChild; const name = text?.textContent?.trim().replace(/ \(days\)$/, ''); if (!text || !descriptions[name]) return; if (!text.textContent.includes('(days)')) text.textContent = `${name} (days)`; let help = field.querySelector('small'); if (!help) { help = document.createElement('small'); field.append(help); } help.textContent = descriptions[name]; }); } +setTimeout(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 0); setInterval(() => { cleanRetentionLabels(); normalizeRetentionLayout(); }, 300); +function renderRetentionRunStatus() { const panel = document.querySelector('[data-admin-panel="retention"]'); const form = panel?.querySelector('.retention-form'); if (!panel || !form) return; const value = state.settings?.logsRetention?.lastRunAt ? state.settings.logsRetention : null; let status = panel.querySelector('.retention-run-status'); if (!status) { status = document.createElement('div'); status.className = 'retention-run-status muted'; const actions = form.querySelector('.dialog-actions'); if (actions) actions.before(status); else form.append(status); } status.textContent = value ? `Last run: ${value.lastRunMode || 'manual'} · ${new Date(value.lastRunAt).toLocaleString()} · Snapshot: ${value.lastRunSnapshot || 'available'}` : 'No pruning run yet.'; } +async function renderRetentionPreview() { const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let preview = panel.querySelector('.retention-preview'); if (!preview) { preview = document.createElement('div'); preview.className = 'retention-preview muted'; const form = panel.querySelector('.retention-form'); const status = panel.querySelector('.retention-run-status'); (status || form)?.before(preview); } try { const data = await api('/api/logs/prune/preview'); const counts = data.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + Number(value || 0), 0); preview.textContent = data.enabled ? `Eligible to prune: ${total} records · Access ${counts.access || 0} · Activity ${counts.activity || 0} · Certificates ${counts.certificate || 0} · Security ${counts.security || 0} · Audit ${counts.audit || 0}` : 'Pruning is disabled. Enable automatic pruning to preview eligible records.'; } catch { preview.textContent = 'Prune preview unavailable.'; } } +async function renderRetentionHistory() { const panel = document.querySelector('[data-admin-panel="retention"]'); if (!panel) return; let history = panel.querySelector('.retention-history'); if (!history) { history = document.createElement('div'); history.className = 'retention-history'; (panel.querySelector('.retention-run-status') || panel.querySelector('.retention-form'))?.after(history); } try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(0, 50); history.innerHTML = `
Prune history${rows.length} runs
` + (rows.length ? `
${rows.map(item => `
${extendedEscape(item.action)}${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}
`).join('')}
` : '

No pruning runs recorded yet.

'); } catch { history.innerHTML = '

Prune history unavailable.

'; } } +function ensureRetentionLoadMore() { const history = document.querySelector('.retention-history'); if (!history || history.querySelector('[data-retention-load-more]')) return; const button = document.createElement('button'); button.className = 'text-button retention-load-more'; button.dataset.retentionLoadMore = 'true'; button.textContent = 'Load more'; history.append(button); } +document.addEventListener('click', async event => { const button = event.target.closest('[data-retention-load-more]'); if (!button) return; try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(50); const list = button.parentElement.querySelector('.retention-history-list'); rows.forEach(item => { const row = document.createElement('div'); row.className = 'retention-history-row'; row.innerHTML = `${extendedEscape(item.action)}${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}`; list?.append(row); }); button.remove(); } catch { button.textContent = 'History unavailable'; } }); +function normalizeRetentionActions() { const form = document.querySelector('[data-admin-panel="retention"] .retention-form'); const actions = form?.querySelector('.dialog-actions'); const section = form?.querySelector('.form-section'); if (form && actions && section && actions.previousElementSibling !== section) section.after(actions); } +async function expandRetentionHistory() { const history = document.querySelector('.retention-history'); const list = history?.querySelector('.retention-history-list'); if (!history || !list || history.dataset.expanded) return; history.dataset.expanded = 'true'; try { const rows = (await api('/api/audit?action=pruning')).filter(item => /pruning/i.test(item.action)).slice(50, 200); rows.forEach(item => { const row = document.createElement('div'); row.className = 'retention-history-row'; row.innerHTML = `${extendedEscape(item.action)}${extendedEscape(item.actor || 'System')} · ${extendedEscape(item.status === 'error' ? 'Failed' : 'Success')} · ${extendedEscape(formatTime(item.created_at))}`; list.append(row); }); } catch { /* Keep the initial 50 records if expansion is unavailable. */ } } +setInterval(ensureRetentionLoadMore, 500); setInterval(normalizeRetentionActions, 500); setInterval(expandRetentionHistory, 1000); +setInterval(renderRetentionRunStatus, 500); setInterval(renderRetentionPreview, 2000); setInterval(() => { if (document.querySelector('[data-admin-panel="retention"]:not(.hidden)') && !document.querySelector('.retention-history')) renderRetentionHistory(); }, 1000); setTimeout(renderRetentionPreview, 0); setTimeout(renderRetentionHistory, 0); +document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; try { const response = await api("/api/logs/prune", { method: "POST" }); const total = Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0); toast(`Pruning completed. ${total} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }); +document.addEventListener("click", event => { const button = event.target.closest('[data-retention-action="download"]'); if (!button) return; window.location.href = "/api/logs/download"; }); +document.addEventListener("click", async event => { const button = event.target.closest('[data-retention-action="prune"]'); if (!button) return; event.preventDefault(); event.stopImmediatePropagation(); try { const preview = await api("/api/logs/prune/preview"); const counts = preview.counts || {}; const total = Object.values(counts).reduce((sum, value) => sum + value, 0); let dialog = document.querySelector("#retention-prune-dialog"); if (!dialog) { dialog = document.createElement("dialog"); dialog.id = "retention-prune-dialog"; document.body.append(dialog); } dialog.innerHTML = `

Log Retention

Confirm pruning

This will remove records older than your saved retention periods.

Access: ${counts.access || 0} · Activity: ${counts.activity || 0} · Certificates: ${counts.certificate || 0} · Security: ${counts.security || 0} · Audit: ${counts.audit || 0}

`; dialog.showModal(); const result = await new Promise(resolve => dialog.addEventListener("close", () => resolve(dialog.returnValue), { once: true })); if (result !== "confirm") return; const response = await api("/api/logs/prune", { method: "POST" }); toast(`Pruning completed. ${Object.values(response.counts || {}).reduce((sum, value) => sum + value, 0)} record${total === 1 ? "" : "s"} removed.`); } catch (error) { toast(error.message); } }, true); diff --git a/src/public/icon.png b/src/public/icon.png new file mode 100644 index 0000000..07d293b Binary files /dev/null and b/src/public/icon.png differ diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 0000000..235bcf9 --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,276 @@ + + + + + + + Site Gateway + + + + + + + + +
+ Site Gateway +

First-time setup

+

Secure your administrator account

+

Confirm or change the administrator details below. The credentials supplied during installation were used only to bootstrap this account.

+ + + + + + +
+
+ + + + +
+

New destination

Create a site

+ + + + + +
Advanced options
+ + +
+
+
+ + +
+

New route

Create a proxy host

+ + + + + + + +
Both files are required when installing or replacing a custom certificate.
+
Advanced options

Custom locations Optional

Headers and upstream TLS

+ +
+
+
+

New route

Create a redirect host

+

Reusable protection

Create an Access List

+ + +
+

Gateway settings

Edit route

+ + + + + + +
Both files are required when installing or replacing a custom certificate.
+
Advanced options
+
Advanced options
+ +
+
+
+ + +

Delete this site?

Its uploaded files will be permanently removed.

+
+ +

Domain readiness

Diagnostics

+
+ +
+

Appearance

Choose an icon

+

Search Dashboard Icons. Selected icons are validated and stored locally in /data/icons.

+ + + +

Enter at least two characters to search.

+ +
+
+
+ +
+

Administration

Create a user

+ + + + + +
+
+
+ +
+

Credentials

Reset password

+ + +
+
+
+ +
+ + + diff --git a/src/public/site-404.html b/src/public/site-404.html new file mode 100644 index 0000000..4e907f1 --- /dev/null +++ b/src/public/site-404.html @@ -0,0 +1 @@ +Not found

404

This file doesn’t exist on this site.

diff --git a/src/public/site-gateway-icon-approved.png b/src/public/site-gateway-icon-approved.png new file mode 100644 index 0000000..d816190 Binary files /dev/null and b/src/public/site-gateway-icon-approved.png differ diff --git a/src/public/site-gateway-lockup-approved.png b/src/public/site-gateway-lockup-approved.png new file mode 100644 index 0000000..18de245 Binary files /dev/null and b/src/public/site-gateway-lockup-approved.png differ diff --git a/src/public/site-gateway-lockup.svg b/src/public/site-gateway-lockup.svg new file mode 100644 index 0000000..fe1d761 --- /dev/null +++ b/src/public/site-gateway-lockup.svg @@ -0,0 +1,16 @@ + + Site Gateway + Site Gateway wordmark with a green and blue gateway icon and colored routing nodes. + + + + + + + + + + + Site + Gateway + diff --git a/src/public/styles.css b/src/public/styles.css new file mode 100644 index 0000000..ae64d89 --- /dev/null +++ b/src/public/styles.css @@ -0,0 +1,261 @@ +:root{--bg:#08101d;--panel:#101a2b;--panel2:#142136;--line:#23334d;--text:#f4f7fb;--muted:#91a0b6;--green:#62e6a7;--blue:#79a9ff;--danger:#ff7185;--warning:#ffbf69;--shadow:0 24px 70px rgba(0,0,0,.35);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:dark} +*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 75% -10%,#172b51 0,transparent 35%),var(--bg);color:var(--text);min-height:100vh}button,input{font:inherit}.hidden{display:none!important}.app-shell{display:grid;grid-template-columns:240px 1fr;min-height:100vh}aside{border-right:1px solid var(--line);padding:28px 20px;display:flex;flex-direction:column;background:rgba(8,16,29,.78);backdrop-filter:blur(20px)}.brand{display:flex;align-items:center;gap:12px;font-weight:750;letter-spacing:-.02em}.brand-mark{width:52px;height:52px;border-radius:15px;display:grid;place-items:center;background:linear-gradient(145deg,var(--green),#2fbf91);color:#06251a;font-weight:900;box-shadow:0 10px 30px rgba(98,230,167,.18)}.brand-mark.small{width:34px;height:34px;border-radius:10px}.eyebrow{text-transform:uppercase;letter-spacing:.16em;font-size:.7rem;color:var(--green);font-weight:800;margin:0 0 9px}nav{margin-top:42px}nav button{width:100%;border:0;border-radius:10px;padding:12px;text-align:left;background:transparent;color:var(--muted);display:flex;justify-content:space-between}nav .nav-active{background:var(--panel2);color:var(--text)}nav span{color:var(--green)}.aside-footer{margin-top:auto;padding:16px 10px 0;border-top:1px solid var(--line);display:flex;justify-content:space-between;color:var(--muted);font-size:.85rem}.text-button{border:0;background:none;color:var(--blue);cursor:pointer}main{padding:58px clamp(28px,6vw,88px);max-width:1500px;width:100%;margin:auto}header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px}h1{font-size:clamp(2rem,4vw,3.2rem);letter-spacing:-.045em;margin:0 0 8px;line-height:1}h2{letter-spacing:-.025em}.muted{color:var(--muted);margin:0;line-height:1.6}.button{border:1px solid transparent;border-radius:10px;padding:11px 16px;color:var(--text);font-weight:720;cursor:pointer}.button:disabled{opacity:.55;cursor:wait}.primary{background:var(--green);color:#05251a;box-shadow:0 10px 30px rgba(98,230,167,.13)}.secondary{background:var(--panel2);border-color:var(--line)}.danger{background:var(--danger);color:#2b0710}.wide{width:100%}.summary{display:flex;align-items:center;gap:28px;margin:40px 0 28px;padding:17px 20px;background:rgba(16,26,43,.75);border:1px solid var(--line);border-radius:14px}.summary>div{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:.88rem}.summary strong{color:var(--text)}.port-note{margin-left:auto}.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#61708a}.status-dot.running{background:var(--green);box-shadow:0 0 0 4px rgba(98,230,167,.1)}.status-dot.disabled{background:var(--danger);box-shadow:0 0 0 4px rgba(255,113,133,.09)}.status-dot.error,.status-dot.idle{background:var(--warning);box-shadow:0 0 0 4px rgba(255,191,105,.09)}.site-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px}.site-card{position:relative;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));border:1px solid var(--line);border-radius:16px;padding:20px;min-height:220px;box-shadow:0 14px 40px rgba(0,0,0,.14);transition:.2s}.site-card:hover{transform:translateY(-2px);border-color:#344b6e}.card-top,.card-footer,.card-actions{display:flex;align-items:center;justify-content:space-between}.site-icon{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:#1c3050;color:var(--blue);font-size:.78rem;font-weight:850}.site-card h2{margin:27px 0 5px;font-size:1.15rem}.address{margin:0;color:var(--muted);font-family:ui-monospace,monospace;font-size:.82rem}.card-footer{position:absolute;left:20px;right:20px;bottom:20px}.status-pill{display:flex;align-items:center;gap:8px;text-transform:capitalize;font-size:.78rem;color:var(--muted)}.icon-button,.launch{width:34px;height:34px;border-radius:9px;border:1px solid var(--line);display:grid;place-items:center;background:#101b2e;color:var(--muted);cursor:pointer;text-decoration:none}.menu-wrap{position:relative}.menu{display:none;position:absolute;right:0;top:40px;width:145px;background:#17243a;border:1px solid var(--line);border-radius:10px;padding:6px;box-shadow:var(--shadow);z-index:3}.menu-open .menu{display:block}.menu button{border:0;background:none;color:var(--text);display:block;width:100%;text-align:left;padding:9px;border-radius:7px;cursor:pointer}.menu button:hover{background:#21314b}.menu .danger-text{color:var(--danger)}.toggle{width:39px;height:22px;border:0;border-radius:20px;background:#344259;padding:3px;cursor:pointer}.toggle span{display:block;width:16px;height:16px;border-radius:50%;background:#d7deea;transition:.2s}.toggle.on{background:#278764}.toggle.on span{transform:translateX(17px);background:white}.card-actions{gap:8px}.empty{text-align:center;border:1px dashed #30415e;border-radius:18px;padding:64px 24px}.empty-icon{font-size:2rem;color:var(--green)}.empty h2{margin:14px 0 8px}.empty p{color:var(--muted);margin:0 auto 24px;max-width:430px}.login-shell{min-height:100vh;display:grid;place-items:center;padding:20px}.login-card,.dialog-card{width:min(440px,100%);background:rgba(16,26,43,.96);border:1px solid var(--line);border-radius:18px;padding:30px;box-shadow:var(--shadow)}.login-card .brand-mark{margin-bottom:28px}.login-card h1{font-size:2.25rem}.login-card>.muted{margin-bottom:24px}label{display:block;color:#c7d0de;font-size:.82rem;font-weight:650;margin:16px 0 0}input{display:block;width:100%;margin-top:7px;border:1px solid var(--line);border-radius:9px;padding:12px;background:#0b1525;color:var(--text);outline:none}input:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(98,230,167,.1)}.login-card .button{margin-top:20px}.error{min-height:1.2em;color:var(--danger);font-size:.82rem}dialog{border:0;padding:0;background:transparent;color:var(--text);width:min(500px,calc(100% - 28px))}dialog::backdrop{background:rgba(2,7,14,.76);backdrop-filter:blur(5px)}.dialog-card{width:100%}.dialog-card.compact{padding:26px}.dialog-heading{display:flex;justify-content:space-between}.dialog-heading h2,.compact h2{margin:0;font-size:1.65rem}.dropzone{border:1px dashed #3a4d6b;border-radius:12px;padding:24px;text-align:center;cursor:pointer}.dropzone input{display:none}.dropzone span,.dropzone strong,.dropzone small{display:block}.upload-icon{font-size:1.5rem;color:var(--green);margin-bottom:7px}.dropzone small,label small{font-weight:400;color:var(--muted);margin-top:5px}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:24px}.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;background:#eef6ff;color:#0b1626;padding:11px 16px;border-radius:9px;box-shadow:var(--shadow);transition:.2s;pointer-events:none}.toast.show{opacity:1;transform:translate(-50%,0)} +@media(max-width:760px){.app-shell{display:block}aside{display:none}main{padding:32px 18px}header{align-items:flex-start;flex-direction:column}.summary{gap:14px;flex-wrap:wrap}.port-note{width:100%;margin:0;padding-top:10px;border-top:1px solid var(--line)}.site-grid{grid-template-columns:1fr}} + +/* Header account controls and state-aware summary indicators */ +.header-actions{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap}.theme-control{display:flex;align-items:center;gap:8px;margin:0;padding:7px 9px 7px 12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--muted);font-size:.78rem}.theme-control select{border:0;background:transparent;color:var(--text);font:inherit;font-weight:700;outline:none;cursor:pointer}.account-control{display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--muted);font-size:.78rem}.account-control strong{color:var(--text)}.status-dot.inactive{background:#718096;box-shadow:0 0 0 4px rgba(113,128,150,.09)} + +/* Light theme */ +:root[data-theme="light"]{color-scheme:light;--bg:#f3f6fa;--panel:#fff;--panel2:#e8eef6;--line:#d6dfeb;--text:#132033;--muted:#637188;--green:#138a5b;--blue:#2563ad;--danger:#cf334d;--warning:#c57a08;--shadow:0 24px 70px rgba(34,54,80,.14)} +:root[data-theme="light"] body{background:radial-gradient(circle at 75% -10%,#dfeaff 0,transparent 36%),var(--bg)}:root[data-theme="light"] aside{background:rgba(255,255,255,.82)}:root[data-theme="light"] .site-card{background:linear-gradient(145deg,#fff,#f6f9fc);box-shadow:0 14px 40px rgba(49,71,99,.08)}:root[data-theme="light"] .summary{background:rgba(255,255,255,.82)}:root[data-theme="light"] .site-icon{background:#e5eefb;color:#275c9c}:root[data-theme="light"] .icon-button,:root[data-theme="light"] .launch{background:#f5f8fc}:root[data-theme="light"] .menu{background:#fff}:root[data-theme="light"] .menu button:hover{background:#edf2f8}:root[data-theme="light"] input{background:#fff}:root[data-theme="light"] .login-card,:root[data-theme="light"] .dialog-card{background:rgba(255,255,255,.97)}:root[data-theme="light"] .toggle{background:#aab5c4}:root[data-theme="light"] .toast{background:#132033;color:#f5f8fc} + +@media(max-width:1050px){header{align-items:flex-start;flex-direction:column}.header-actions{justify-content:flex-start}}@media(max-width:760px){.header-actions{width:100%}.account-control{order:3;width:100%;justify-content:space-between}.summary>div:not(.port-note){min-width:calc(50% - 10px)}} + +nav{display:grid;gap:6px}nav .nav-planned{display:grid;grid-template-columns:1fr auto auto;gap:9px;cursor:default}nav .nav-planned::after{content:"Soon";color:var(--muted);font-size:.62rem;text-transform:uppercase;letter-spacing:.08em}nav .nav-planned span{color:var(--muted)} + +select{display:block;width:100%;margin-top:7px;border:1px solid var(--line);border-radius:9px;padding:12px;background:#0b1525;color:var(--text);outline:none}.theme-control select{display:inline-block;width:auto;margin:0;padding:0;border:0}.optional{float:right;color:var(--muted);font-weight:400}.check-control{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--line);border-radius:10px;background:rgba(8,16,29,.28);cursor:pointer}.check-control input{width:17px;height:17px;margin:0;accent-color:var(--green)}.check-control span{line-height:1.35}.gateway-address{margin:7px 0 0;color:var(--blue);font-size:.78rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.gateway-address.secure::before{content:"TLS · ";color:var(--green);font-weight:700}.site-card.proxy .site-icon{color:#cda4ff;background:#2b2043}:root[data-theme="light"] select{background:#fff}:root[data-theme="light"] .check-control{background:#f6f9fc}:root[data-theme="light"] .site-card.proxy .site-icon{background:#eee5fa;color:#7444a7} + +main{margin:0 auto;align-self:start}input[hidden]{display:none!important} + +main{padding-top:24px}.utility-bar{display:flex;align-items:center;justify-content:flex-end;gap:10px;min-height:38px;margin-bottom:42px}header{align-items:flex-end}@media(max-width:1050px){header{align-items:flex-end;flex-direction:row}}@media(max-width:760px){main{padding-top:18px}.utility-bar{margin-bottom:34px;flex-wrap:wrap}.account-control{order:initial;width:auto}.utility-bar .account-control{width:auto}header{align-items:flex-start;flex-direction:column}} +.product-icon{object-fit:contain;background:transparent;box-shadow:none} +.dialog-card input:not([type="checkbox"]):not([type="file"]),.dialog-card select{font:inherit;width:100%;height:44px;min-height:44px;padding:0 12px;line-height:42px;border-width:1px}.aside-footer strong{color:var(--text);font-weight:750} + +/* Dashboard */ +.mobile-nav{display:none}.dashboard-view{margin-top:38px}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.metric-card{min-width:0;padding:20px;border:1px solid var(--line);border-radius:15px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));color:var(--text);text-align:left}.metric-card:is(button){cursor:pointer;transition:.2s}.metric-card:is(button):hover{transform:translateY(-2px);border-color:#3b5275}.metric-card .metric-label,.metric-card>span:last-child{display:block}.metric-card .metric-label{color:var(--muted);font-size:.78rem;font-weight:700}.metric-card strong{display:block;margin:13px 0 7px;font-size:2rem;line-height:1}.metric-card>span:last-child{color:var(--muted);font-size:.76rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.metric-card.attention strong{color:var(--warning)}.dashboard-columns{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.dashboard-columns.lower{align-items:start}.dashboard-panel{min-width:0;padding:22px;border:1px solid var(--line);border-radius:16px;background:rgba(16,26,43,.76)}.panel-heading{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:20px}.panel-heading .eyebrow{margin-bottom:5px}.panel-heading h2{margin:0;font-size:1.2rem}.health-badge{padding:6px 9px;border-radius:999px;font-size:.7rem;font-weight:800}.health-badge.healthy{background:rgba(98,230,167,.12);color:var(--green)}.health-badge.warning{background:rgba(255,191,105,.12);color:var(--warning)}.health-badge.error{background:rgba(255,113,133,.12);color:var(--danger)}.health-list{display:grid;gap:3px}.health-list>div{display:flex;align-items:center;gap:13px;padding:13px 3px;border-top:1px solid var(--line)}.health-list>div:first-child{border-top:0}.health-list span:last-child,.dashboard-list-item span:last-child{display:grid;gap:3px}.health-list small,.dashboard-list-item small{color:var(--muted);font-size:.75rem}.system-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin:0}.system-grid div{min-width:0}.system-grid dt{color:var(--muted);font-size:.72rem}.system-grid dd{margin:6px 0 0;font-size:.9rem;font-weight:750;overflow:hidden;text-overflow:ellipsis}.dashboard-list{display:grid}.dashboard-list-item{display:flex;align-items:flex-start;gap:12px;padding:13px 3px;border-top:1px solid var(--line)}.dashboard-list-item:first-child{border-top:0}.dashboard-list-item strong{font-size:.82rem}.dashboard-list-item .status-dot{margin-top:4px;flex:0 0 auto}.activity-mark{display:grid;place-items:center;width:20px;height:20px;border-radius:50%;background:rgba(98,230,167,.12);color:var(--green);font-size:.7rem;font-weight:900}.quiet-state{color:var(--muted);font-size:.82rem;margin:3px 0} +:root[data-theme="light"] .metric-card{background:linear-gradient(145deg,#fff,#f6f9fc)}:root[data-theme="light"] .dashboard-panel{background:rgba(255,255,255,.82)} +@media(max-width:1100px){.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:760px){.mobile-nav{display:grid;grid-template-columns:repeat(5,1fr);gap:5px;margin:0 0 30px;padding:4px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.mobile-nav button{justify-content:center;text-align:center;padding:9px 5px;font-size:.72rem}.dashboard-view{margin-top:30px}.metric-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.metric-card{padding:16px}.metric-card strong{font-size:1.65rem}.dashboard-columns{grid-template-columns:1fr}.system-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} + +.upstream-copy{margin:10px 0 0;color:var(--green);font-size:.72rem}.upstream-copy.bad{color:var(--danger)}.activity-mark.bad{background:rgba(255,113,133,.12);color:var(--danger)}.feature-view{margin-top:38px}.feature-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:18px}.feature-summary>div{padding:18px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}.feature-summary strong,.feature-summary span{display:block}.feature-summary strong{font-size:1.55rem}.feature-summary span{margin-top:5px;color:var(--muted);font-size:.76rem}.data-list{border:1px solid var(--line);border-radius:16px;background:var(--panel);overflow:hidden}.data-row{display:grid;grid-template-columns:auto minmax(160px,1.3fr) repeat(2,minmax(150px,1fr));align-items:center;gap:16px;padding:17px 20px;border-top:1px solid var(--line)}.data-row:first-child{border-top:0}.data-row>div{display:grid;gap:4px;min-width:0}.data-row strong{overflow:hidden;text-overflow:ellipsis}.data-row small{color:var(--muted)}.log-toolbar{display:flex;align-items:end;justify-content:space-between;gap:16px}.log-toolbar label{margin:0;min-width:250px}.feature-note{margin:16px 0}.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:15px;background:var(--panel)}.log-table{width:100%;border-collapse:collapse;font-size:.82rem}.log-table th,.log-table td{padding:13px 15px;text-align:left;border-top:1px solid var(--line);white-space:nowrap}.log-table thead th{border-top:0;color:var(--muted);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em}.http-status{color:var(--green);font-weight:800}.http-status.bad{color:var(--danger)}.log-activity{margin-top:18px} +@media(max-width:760px){.data-row{grid-template-columns:auto 1fr}.data-row>div:nth-of-type(n+2){grid-column:2}.feature-summary{gap:8px}.feature-summary>div{padding:13px}.log-toolbar{align-items:stretch;flex-direction:column}.log-toolbar label{min-width:0}} + +/* Corrective layout pass: keep controls, indicators, and card footers visually consistent. */ +#log-host{height:44px;min-height:44px;padding:0 12px;line-height:42px;font:inherit}.status-dot{width:8px;min-width:8px;height:8px;min-height:8px;aspect-ratio:1;border-radius:50%;flex:0 0 8px}.site-card{min-height:245px;padding-bottom:76px}.site-card .card-footer{min-height:34px}.upstream-copy{margin:12px 0 8px;line-height:1.45} + +.role-callout{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;padding:18px 20px;margin-bottom:18px;border:1px solid var(--line);border-radius:14px;background:var(--panel);font-size:.8rem}.role-callout span{color:var(--muted)}.user-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:18px}.user-card{padding:20px;border:1px solid var(--line);border-radius:16px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95))}.user-card-head{display:flex;align-items:center;justify-content:space-between}.user-avatar{width:42px;height:42px;display:grid;place-items:center;border-radius:12px;background:#1c3050;color:var(--blue);font-size:.78rem;font-weight:850}.user-card h2{margin:20px 0 5px;font-size:1.12rem}.user-card h2 small{padding:3px 6px;margin-left:5px;border-radius:999px;background:var(--panel2);color:var(--muted);font-size:.62rem;vertical-align:middle}.user-meta{display:grid;gap:5px;margin:18px 0;color:var(--muted);font-size:.75rem}.user-meta span:first-child{color:var(--text);font-weight:750}.user-actions{display:flex;flex-wrap:wrap;gap:8px}.user-actions .button{padding:8px 10px;font-size:.72rem}.danger-text{color:var(--danger)!important}:root[data-theme="light"] .user-card{background:linear-gradient(145deg,#fff,#f6f9fc)} +@media(max-width:760px){.mobile-nav{grid-template-columns:repeat(3,1fr)}.role-callout{grid-template-columns:1fr}.role-callout strong:not(:first-child){margin-top:8px}.user-grid{grid-template-columns:1fr}} +@media(max-width:420px){.metric-grid{grid-template-columns:1fr}.system-grid{grid-template-columns:1fr 1fr}} + +/* Health, menus, and locally cached service icons */ +.health-actions{display:flex;align-items:center;gap:8px}.health-actions .icon-button{width:29px;height:29px;font-size:1rem}.checked-time{margin:14px 0 0;padding-top:13px;border-top:1px solid var(--line);color:var(--muted);font-size:.7rem}.spinning{animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.system-grid small{display:block;margin-top:4px;color:var(--muted);font-size:.65rem;line-height:1.35}.site-icon{overflow:hidden;padding:7px}.site-icon img{display:block;width:100%;height:100%;object-fit:contain}.menu button:focus-visible,.icon-choice:focus-visible{outline:2px solid var(--green);outline-offset:1px}.icon-dialog{width:min(720px,calc(100% - 28px))}.icon-picker{max-width:none}.icon-picker>.muted{margin-top:10px}.icon-results{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:9px;min-height:112px;max-height:330px;margin-top:16px;padding:2px;overflow:auto}.icon-results>.quiet-state{grid-column:1/-1;padding:28px 0;text-align:center}.icon-choice{display:grid;place-items:center;gap:8px;min-width:0;padding:12px 7px;border:1px solid var(--line);border-radius:11px;background:var(--panel2);color:var(--text);cursor:pointer}.icon-choice:hover{border-color:var(--green)}.icon-choice img{width:42px;height:42px;object-fit:contain}.icon-choice span{width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.68rem}:root[data-theme="light"] .icon-choice{background:#f6f9fc} +@media(max-width:600px){.icon-results{grid-template-columns:repeat(3,minmax(0,1fr))}.icon-picker .dialog-actions{flex-wrap:wrap}.icon-picker .dialog-actions button:first-child{width:100%}} + +/* Administration, documentation, redirects, and progressive disclosure */ +.aside-utilities{margin-top:auto;padding:16px 0 14px;border-top:1px solid var(--line);display:grid;gap:4px}.aside-utilities button{width:100%;border:0;border-radius:10px;padding:11px 12px;text-align:left;background:transparent;color:var(--muted);cursor:pointer}.aside-utilities button:hover,.aside-utilities button.nav-active{background:var(--panel2);color:var(--text)}.aside-footer{margin-top:0}.admin-tabs{display:flex;gap:7px;margin-bottom:22px;padding:5px;border:1px solid var(--line);border-radius:12px;background:var(--panel);overflow:auto}.admin-tabs button{width:auto;white-space:nowrap;padding:9px 12px;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}.admin-tabs .tab-active{background:var(--panel2);color:var(--text)}.settings-panel{max-width:980px}.settings-panel>h2{margin:0 0 6px}.settings-form{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin-top:22px;padding:22px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}.settings-form>label:has(textarea),.settings-form>.dialog-actions,.settings-form>.error{grid-column:1/-1}.settings-form.compact-grid{grid-template-columns:repeat(4,1fr)}.settings-form.compact-grid .check-control,.settings-form.compact-grid .dialog-actions{grid-column:auto}textarea{display:block;width:100%;min-height:110px;margin-top:7px;padding:12px;border:1px solid var(--line);border-radius:9px;background:#0b1525;color:var(--text);font:inherit;resize:vertical;outline:none}textarea:focus{border-color:var(--green);box-shadow:0 0 0 3px rgba(98,230,167,.1)}.code-input{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.78rem;min-height:150px}.callout{display:flex;gap:10px;margin:18px 0;padding:16px 18px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);font-size:.78rem}.callout span{color:var(--muted)}.row-actions{display:flex!important;grid-auto-flow:column!important;justify-content:end;gap:7px}.row-actions .button{padding:8px 10px;font-size:.7rem;text-decoration:none}.backup-row{grid-template-columns:auto minmax(220px,1.5fr) 1fr .7fr auto}.padded{padding:22px}.redirect-card .site-icon{background:#163c35;color:var(--green)}.redirect-card .card-footer{gap:12px}.redirect-card .button{padding:7px 9px;font-size:.68rem}.chip{padding:5px 8px;border-radius:999px;background:var(--panel2);color:var(--muted);font-size:.68rem}details{margin-top:18px;border:1px solid var(--line);border-radius:12px;background:rgba(8,16,29,.28)}summary{padding:14px 16px;color:var(--text);font-weight:750;cursor:pointer}.details-body{padding:0 16px 16px;border-top:1px solid var(--line)}.subform{margin-top:18px;padding:16px;border:1px solid var(--line);border-radius:12px}.docs{display:grid;grid-template-columns:260px 1fr;gap:24px}.doc-search{position:sticky;top:20px;align-self:start;margin:0}.docs-content{display:grid;gap:14px}.docs article{padding:25px;border:1px solid var(--line);border-radius:15px;background:var(--panel)}.docs article h2{margin:0 0 10px}.docs article p,.docs article li{color:var(--muted);line-height:1.65}.docs article code{color:var(--green)}:root[data-theme="light"] textarea{background:#fff}:root[data-theme="light"] details{background:#f6f9fc} +@media(max-width:900px){.settings-form.compact-grid{grid-template-columns:1fr 1fr}.settings-form .form-grid{grid-template-columns:1fr 1fr}.backup-row{grid-template-columns:auto 1fr}.backup-row>div{grid-column:2}.row-actions{justify-content:start}.docs{grid-template-columns:1fr}.doc-search{position:static}}@media(max-width:600px){.settings-form,.settings-form.compact-grid{grid-template-columns:1fr}.settings-form .form-grid{grid-template-columns:1fr}.settings-form>*{grid-column:1!important}.admin-tabs{margin-left:-5px;margin-right:-5px}} +#custom-certificate-fields,.custom-certificate-fields{display:none;margin-top:16px;padding:14px;border:1px solid var(--line);border-radius:12px}.custom-certificate-visible{display:block!important} +dialog{max-height:calc(100vh - 28px);overflow:auto} +.setup-dialog{width:min(520px,calc(100% - 28px))}.setup-dialog::backdrop{background:rgba(2,7,14,.9)}.setup-card{max-width:none}.setup-card .brand-mark{margin-bottom:24px}.setup-card h1{margin:0 0 10px;font-size:clamp(1.8rem,5vw,2.35rem);letter-spacing:-.04em}.setup-card>.muted{margin-bottom:22px}.setup-card .button{margin-top:20px} +.feature-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:16px}.certificates-view .feature-summary,#certificates-view .feature-summary{grid-template-columns:repeat(5,minmax(0,1fr))}.certificate-row{margin:0;border:0;border-top:1px solid var(--line);border-radius:0;background:transparent}.certificate-row:first-child{border-top:0}.certificate-row summary{display:grid;grid-template-columns:auto minmax(180px,1.2fr) minmax(220px,1fr);align-items:center;gap:16px;padding:18px 20px}.certificate-row summary>span{display:grid;gap:4px}.certificate-row small{color:var(--muted)}.certificate-details{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 22px;margin:0;padding:18px 54px 22px;background:rgba(8,16,29,.22);border-top:1px solid var(--line)}.certificate-details dt{color:var(--muted);font-size:.7rem}.certificate-details dd{margin:5px 0 0;overflow-wrap:anywhere;font-size:.8rem}.readiness-panel{margin-top:18px}.issue-link{width:100%;border:0;background:transparent;color:var(--text);text-align:left;cursor:pointer}.issue-link:hover{background:var(--panel2)}@media(max-width:900px){#certificates-view .feature-summary{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.feature-toolbar{align-items:stretch;flex-direction:column}.feature-toolbar .row-actions{justify-content:stretch}.feature-toolbar .button{flex:1}.certificate-row summary{grid-template-columns:auto 1fr}.certificate-row summary>span:last-child{grid-column:2}.certificate-details{grid-template-columns:1fr;padding-left:52px}} +.settings-panel{max-width:none}.log-filters{display:flex;align-items:end;gap:12px}.log-filters label{min-width:210px;margin:0}#log-host,#log-status{width:100%;height:44px;min-height:44px;box-sizing:border-box;padding:0 12px;line-height:42px}.readiness-row{cursor:pointer}.readiness-row:hover,.readiness-row:focus-visible{background:var(--panel2);outline:none}.readiness-hint{color:var(--muted);font-size:.68rem}.readiness-dialog-card{width:min(620px,calc(100% - 28px))}.readiness-dialog-card .readiness-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 20px;margin:8px 0}.readiness-dialog-card .readiness-detail-grid dt{color:var(--muted);font-size:.7rem}.readiness-dialog-card .readiness-detail-grid dd{margin:5px 0 0;font-size:.82rem;overflow-wrap:anywhere}.readiness-dialog-card .danger-text{color:var(--danger)}#log-last-checked{color:var(--muted)}@media(max-width:760px){.log-filters{align-items:stretch;flex-direction:column}.log-filters label{min-width:0}.readiness-dialog-card .readiness-detail-grid{grid-template-columns:1fr}} +.access-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:18px}.access-card h2{margin-top:20px}.access-card-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:20px}.access-card-stats span{display:grid;gap:4px}.access-card-stats strong{font-size:1.15rem}.access-card-stats small{color:var(--muted);font-size:.68rem}#access-list{border:0;background:transparent;overflow:visible;padding:0}.group-card{min-height:0} +.credential-editor{margin-top:18px;padding-top:16px;border-top:1px solid var(--line)}.credential-editor-heading{display:flex;align-items:center;justify-content:space-between;gap:12px}.credential-editor-heading strong{font-size:.9rem}.credential-editor>.muted{font-size:.75rem;margin:7px 0}.credential-row{display:grid;grid-template-columns:1fr 1fr auto;align-items:end;gap:8px;margin-top:9px}.credential-row input{margin-top:0}.credential-row .button{padding:10px 11px;font-size:.72rem}@media(max-width:600px){.credential-row{grid-template-columns:1fr 1fr}.credential-row .button{grid-column:2;justify-self:end}} +#app input,#app select,#app textarea{box-sizing:border-box}#app select{height:44px;min-height:44px;padding-top:0;padding-bottom:0;line-height:42px} +#app .access-summary{margin:6px 0 0;color:var(--muted);font-size:.72rem} +#app .site-icon img{width:100%;height:100%;object-fit:contain;border-radius:inherit} +#app .upstream-detail{margin:5px 0 0;color:var(--muted);font-size:.68rem;line-height:1.4} +#app .upstream-diagnostics{margin:7px 0 0;border:0;background:transparent}#app .upstream-diagnostics summary{padding:0;color:var(--blue);font-size:.68rem;font-weight:700;border:0}#app .upstream-diagnostics .upstream-detail{margin:6px 0 0} +.log-table-wrap{max-height:min(52vh,620px);overflow:auto}.log-table-wrap .log-table thead th{position:sticky;top:0;background:var(--panel);z-index:1}.log-section-heading{margin:20px 0 10px}.log-section-heading h2{margin:0;font-size:1.05rem}.log-section-heading p{margin:4px 0 0;font-size:.76rem}@media(min-width:761px){#dashboard>aside{position:sticky;top:0;height:100vh;max-height:100vh;overflow-y:auto;align-self:start}} +.log-table th:nth-last-child(2),.log-table td:nth-last-child(2),.log-table th:last-child,.log-table td:last-child{position:sticky;background:var(--panel);z-index:2}.log-table th:nth-last-child(2),.log-table td:nth-last-child(2){right:104px;min-width:104px}.log-table th:last-child,.log-table td:last-child{right:0;min-width:104px}.log-table th:nth-last-child(2){box-shadow:-1px 0 0 var(--line)}.log-table td:nth-last-child(2){box-shadow:-1px 0 0 var(--line)} +.log-table-wrap{overflow-x:hidden;overflow-y:auto}.log-table{table-layout:fixed}.log-table th:nth-child(1),.log-table td:nth-child(1){width:190px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:220px}.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){width:104px}.log-table th:nth-child(3),.log-table td:nth-child(3){white-space:normal;overflow-wrap:anywhere}.log-table th:last-child,.log-table td:last-child{box-shadow:-1px 0 0 var(--line)} +@media(max-width:900px){.log-table th:nth-child(1),.log-table td:nth-child(1){width:150px}.log-table th:nth-child(2),.log-table td:nth-child(2){width:170px}} +.log-table th:nth-child(4),.log-table td:nth-child(4),.log-table th:nth-child(5),.log-table td:nth-child(5){text-align:center} +#access-dialog .dialog-heading .close-dialog{display:none} +.control-disabled{opacity:.52} +.retention-form{grid-template-columns:repeat(3,minmax(0,1fr));align-items:start}.retention-form>label{display:grid;align-content:start;gap:7px;min-width:0}.retention-form>label input:not([type="checkbox"]){height:44px;min-height:44px;box-sizing:border-box;margin-top:0}.retention-form .check-control{display:flex;align-items:center;gap:10px;align-self:start!important;height:44px!important;min-height:44px!important;margin:0!important;box-sizing:border-box}.retention-form .dialog-actions{grid-column:1/-1} +.retention-form>.check-control{margin-top:0!important;transform:none!important;position:relative!important;top:39px!important} +.retention-form{position:relative;padding-top:53px!important} +.retention-form::before{content:"AUTOMATIC LOG PRUNING";position:absolute;top:30px;left:22px;color:var(--green);font-size:.7rem;line-height:normal;letter-spacing:.16em;font-weight:800} +.retention-form[data-normalized="true"]{padding-top:22px!important} +.retention-form[data-normalized="true"]::before{display:none} +.retention-form[data-normalized="true"] .form-grid>.check-control{align-self:start!important;margin-top:39px!important;height:44px!important;min-height:44px!important;max-width:420px!important;box-sizing:border-box} +.retention-eyebrow{display:none!important} +.retention-actions{justify-content:flex-end!important;align-items:center!important} +.retention-actions [data-retention-action="download"]{order:1} +.retention-actions [data-retention-action="prune"]{order:2;background:var(--green);border-color:var(--green);color:#07131f} +.retention-actions .button,[data-admin-panel="backups"] .panel-heading .row-actions .button{min-height:44px;height:44px;padding:11px 16px!important;border-radius:10px;font-size:.82rem!important;line-height:1.2;font-weight:720;text-transform:none} +.retention-run-status{grid-column:1/-1;margin:14px 0 0;padding:12px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);color:var(--muted);font-size:.78rem;line-height:1.45} +.retention-preview{grid-column:1/-1;margin:14px 0 0;padding:10px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);font-size:.78rem;line-height:1.45} +.retention-history{grid-column:1/-1;width:100%;margin:14px 0 0;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}.retention-history-heading{display:flex;justify-content:space-between;color:var(--text);font-size:.82rem}.retention-history-heading span{color:var(--muted);font-weight:500}.retention-history-list{max-height:190px;overflow-y:auto;margin-top:8px}.retention-history-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--line)}.retention-history-row strong{display:block;font-size:.78rem}.retention-history-row small{display:block;margin-top:3px;color:var(--muted);font-size:.7rem}.retention-form .dialog-actions .button.primary{background:var(--green);border-color:var(--green);color:#05251a} +.retention-form{align-content:start!important} +.retention-form .dialog-actions{border-top:0!important;margin-top:0!important;padding-top:16px;padding-bottom:0!important} +.backup-settings .form-section:first-child>.eyebrow,.retention-form::before{text-transform:uppercase;letter-spacing:.16em} +.retention-form::before{content:"AUTOMATIC LOG PRUNING"} +@media(max-width:900px){.retention-form{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.retention-form{grid-template-columns:1fr}} +.settings-form select,.settings-form input:not([type="checkbox"]),.settings-form textarea{min-height:44px}.settings-form .check-control{min-height:44px;align-self:end}.settings-form .dialog-actions{align-items:center;padding-top:16px;border-top:1px solid var(--line)}.settings-form.compact-grid .dialog-actions{grid-column:1/-1;justify-content:flex-end}.settings-panel .panel-heading{align-items:flex-end;margin-bottom:18px}.settings-panel .panel-heading .row-actions{align-items:center}.settings-panel .callout{align-items:flex-start} +.settings-form select{display:block;width:100%;height:44px;box-sizing:border-box;padding:0 12px;line-height:42px} +.settings-form input:not([type="checkbox"]):not([type="file"]),.settings-form select,.settings-form textarea{border-radius:9px;border:1px solid var(--line)}.settings-form .check-control{border-radius:10px;border:1px solid var(--line)} +#default-site-form input:not([type="checkbox"]),#default-site-form select{height:44px;min-height:44px;box-sizing:border-box;padding:0 12px;line-height:42px;border-radius:9px} +#app input:not([type="checkbox"]):not([type="file"]),#app select{height:44px;min-height:44px;box-sizing:border-box;padding:0 12px;line-height:42px;border:1px solid var(--line);border-radius:9px}#app textarea{box-sizing:border-box;border:1px solid var(--line);border-radius:9px}#app .check-control{min-height:44px;border:1px solid var(--line);border-radius:10px}#app .event-filters select{height:44px;min-height:44px} +.dialog-card input:not([type="checkbox"]):not([type="file"]),.dialog-card select{height:44px;min-height:44px;box-sizing:border-box;padding:0 12px;line-height:42px;border:1px solid var(--line);border-radius:9px}.dialog-card textarea{box-sizing:border-box;border:1px solid var(--line);border-radius:9px}.dialog-card .check-control{min-height:44px;border:1px solid var(--line);border-radius:10px} +#app select,.dialog-card select,.settings-form select,.event-filters select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23f4f7fb' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px;padding-right:42px}:root[data-theme="light"] #app select,:root[data-theme="light"] .dialog-card select,:root[data-theme="light"] .settings-form select,:root[data-theme="light"] .event-filters select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23132033' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E")} +.log-filters select{appearance:none!important;height:44px;min-height:44px;box-sizing:border-box;padding:0 42px 0 12px;line-height:42px;border:1px solid var(--line);border-radius:9px;background-color:var(--panel);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23f4f7fb' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px}:root[data-theme="light"] .log-filters select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23132033' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E")} +#logs-view .log-filters select{display:block;width:100%;height:44px;min-height:44px;margin-top:7px;padding:0 42px 0 12px;border:1px solid var(--line);border-radius:9px;box-sizing:border-box;line-height:42px;appearance:none!important} +.theme-control select{appearance:auto;background-image:none;padding-right:0} +.theme-control{min-height:44px;padding:0;border:0;background:transparent}.theme-control select{appearance:none!important;height:44px;min-height:44px;width:auto;margin:0;padding:0 42px 0 12px;border:1px solid var(--line);border-radius:9px;background-color:var(--panel);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23f4f7fb' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 14px center;background-size:16px;color:var(--text)}:root[data-theme="light"] .theme-control select{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23132033' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E")} +.account-control{min-height:44px;box-sizing:border-box} +.danger-card.caution{border-color:rgba(255,191,105,.6);background:rgba(255,191,105,.07)}.danger-card.caution .eyebrow{color:var(--warning)}.danger-card.caution .button{border-color:rgba(255,191,105,.65);color:var(--text)}#check-health{height:44px;min-height:44px;padding:0 16px;border-radius:10px;box-sizing:border-box} +.danger-zone .danger-card:first-of-type{border-color:rgba(255,191,105,.6);background:rgba(255,191,105,.07)}.danger-zone .danger-card:first-of-type .eyebrow{color:var(--warning)} +.danger-zone .danger-card:first-of-type #restore-defaults{background:var(--warning);color:#2b1b05;border-color:transparent;box-shadow:0 10px 30px rgba(255,191,105,.14)}#check-health{font-size:inherit;font-weight:720} +.danger-zone .danger-card:first-of-type .button{margin-top:18px} +.danger-credentials{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 18px;margin-top:12px}.danger-credentials label{margin-top:0}.danger-credentials input{height:44px;min-height:44px;box-sizing:border-box} +.restore-form .danger-credentials{display:contents}.restore-form .danger-credentials label{margin-top:0} +.danger-form .button{margin-top:18px} +.danger-form{row-gap:18px}.danger-form label{margin-top:0}.danger-form .button{margin-top:0} +.danger-card .danger-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));column-gap:18px;row-gap:18px;margin-top:18px}.danger-card .danger-form .button{margin-top:0;align-self:start}.danger-card .danger-form .danger-credentials{display:contents} +.danger-card .danger-form .error:empty{display:none} +.danger-card .restore-form{row-gap:18px!important}.danger-card .restore-form .danger-credentials{display:contents;margin:0!important}.danger-card .restore-form .button{margin-top:0!important} +.caution-button{background:var(--warning);color:#2b1b05;border-color:transparent;box-shadow:0 10px 30px rgba(255,191,105,.14)} +.support-panel{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:18px 0;padding:20px 22px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}.support-panel h3{margin:0 0 5px;font-size:1.05rem}.support-panel .eyebrow{margin-bottom:6px}.support-note{grid-column:1/-1;margin:0;font-size:.75rem}.support-panel .row-actions{flex:0 0 auto} +#create-backup,#import-backup{height:44px;min-height:44px;padding:0 16px;font-size:.82rem;font-weight:720;border-radius:10px} +.danger-tab{color:var(--danger)!important}.danger-zone>h2{color:var(--danger)}.danger-card{margin-top:18px;padding:22px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}.danger-card h3{margin:0 0 7px}.danger-card p:not(.eyebrow){color:var(--muted);line-height:1.55}.danger-card.destructive{border-color:rgba(255,113,133,.55);background:rgba(255,113,133,.06)}.danger-card.destructive .eyebrow{color:var(--danger)}.danger-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 18px;margin-top:18px}.danger-form .error,.danger-form .button{grid-column:1/-1}.danger-form .button{justify-self:start}.danger-form code{color:var(--danger)} +#backup-settings-form select{appearance:none!important} +.settings-form .form-section{grid-column:1/-1;padding:4px 0 20px;border-bottom:1px solid var(--line)}.settings-form .form-section+.form-section{padding-top:20px}.settings-form .form-section:last-of-type{border-bottom:0}.settings-form .form-section .eyebrow{margin-bottom:12px}.settings-form .form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 18px}.settings-form .form-grid .check-control{align-self:end}.settings-form .form-section-wide .check-control{max-width:420px}.backup-settings .dialog-actions{grid-column:1/-1}.backup-settings code{color:var(--green);font-family:ui-monospace,monospace;font-size:.78rem} +#backup-settings-form select,#backup-settings-form input[type="number"],#backup-settings-form input[type="password"]{display:block;width:100%;height:44px;min-height:44px;margin-top:7px;padding:0 12px;box-sizing:border-box;border:1px solid var(--line);border-radius:9px;background:#0b1525;color:var(--text);line-height:42px}#backup-settings-form select{appearance:auto}#backup-settings-form .check-control{display:flex;align-items:center;height:44px;min-height:44px;margin-top:16px;padding:0 12px}#backup-settings-form .check-control input{width:17px;height:17px;margin:0;line-height:normal}#backup-settings-form .dialog-actions{margin-top:18px;padding-top:16px} +.backup-password-field .field-label{display:flex;align-items:center;justify-content:space-between;gap:10px}.backup-password-field .optional{float:none}.backup-password-field small{display:block;max-width:38rem;line-height:1.45} +.event-filters{display:flex;gap:10px;margin:4px 0 15px}.event-filters label{min-width:180px;margin:0}.event-filters select{width:100%;height:44px;min-height:44px}.event-list{max-height:360px;overflow:auto}.event-row{display:flex;align-items:flex-start;gap:12px;padding:12px 3px;border-top:1px solid var(--line)}.event-row:first-child{border-top:0}.event-row>span:last-child{display:grid;gap:3px}.event-row small{text-transform:capitalize;color:var(--muted);font-size:.7rem}.activity-mark.warn{background:rgba(255,191,105,.12);color:var(--warning)}@media(max-width:600px){.event-filters{flex-direction:column}.event-filters label{min-width:0}} +.dashboard-panel .panel-heading .text-button{white-space:nowrap;font-size:.75rem} +.certificate-status{display:inline-flex;align-items:center;width:max-content;margin:0 0 16px;padding:7px 11px;border:1px solid var(--line);border-radius:999px;background:rgba(98,230,167,.08);color:var(--muted);font-size:.76rem;font-weight:700} +.access-assignment-preview{margin:14px 0 0;color:var(--muted);font-size:.72rem;line-height:1.45;overflow-wrap:anywhere} +.assignment-host-list{display:grid;gap:8px;margin-top:10px}.assignment-host-chip{display:flex;justify-content:space-between;gap:12px;padding:9px 10px;border:1px solid var(--line);border-radius:9px;background:var(--panel2)}.assignment-host-chip b{font-size:.78rem}.assignment-host-chip small{color:var(--muted);font-size:.7rem;text-align:right;overflow-wrap:anywhere} +.assignment-editor{display:grid;gap:8px;margin-top:12px}.assignment-option{display:flex!important;align-items:center;gap:10px;margin:0!important;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--panel);cursor:pointer}.assignment-option input{width:18px!important;height:18px!important;min-height:18px!important;margin:0!important;accent-color:var(--green)}.assignment-option span{display:grid;gap:2px}.assignment-option small{color:var(--muted);font-size:.7rem} +.assignment-search{margin:12px 0 0!important;min-height:42px!important} +.assignment-search-label{display:block!important;margin:12px 0 0!important;color:var(--muted);font-size:.72rem;font-weight:750}.assignment-search-label input{display:block;width:100%;box-sizing:border-box} +.assignment-option[hidden]{display:none!important} +.access-group-selector{display:block!important;margin:12px 0 0!important;padding-top:0;border-top:0}.access-group-selector>strong{display:block}.access-group-help{margin:7px 0 10px;color:var(--muted);font-size:.75rem;line-height:1.45}.access-group-options{display:grid;gap:8px}.access-group-option{margin:0!important;padding:10px 12px;background:var(--panel2);border:1px solid var(--line);border-radius:10px}.access-group-option small{margin-left:4px;color:var(--muted);font-size:.7rem}.access-group-empty{margin:0;color:var(--muted);font-size:.75rem}.access-group-preview{margin:8px 0 0;color:var(--muted);font-size:.72rem} +#access-assignment-summary{width:100%;box-sizing:border-box}#access-assignment-summary.hidden{display:none!important}#access-form[data-editing] #access-assignment-summary{display:block!important}.assignment-search-label{width:100%;box-sizing:border-box}.assignment-search-label input{margin-top:7px!important}.assignment-editor{width:100%;box-sizing:border-box} +#access-assignment-summary>strong{display:block;margin-bottom:6px}#access-assignment-summary>p{margin:0 0 4px;color:var(--muted);line-height:1.45}.access-create-guidance{margin:16px 0 0!important;color:var(--muted);font-size:.75rem;line-height:1.45} +#access-assignment-summary>strong{margin-top:20px;padding-top:16px;border-top:1px solid var(--line)}.access-create-guidance{margin-top:18px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--panel2)}.access-create-guidance strong{display:block}.access-create-guidance p{margin:6px 0 0;color:var(--muted);line-height:1.45} + +.danger-actions{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.danger-actions .button{margin:0} +.docs-intro{margin-bottom:22px;padding:24px;border:1px solid var(--line);border-radius:16px;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95))}.docs-intro h2{margin:0 0 10px}.docs-intro p:last-child{margin:0;color:var(--muted);max-width:850px;line-height:1.6} +.docs-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:22px;align-items:start}.docs-nav{position:sticky;top:18px;height:calc(100vh - 170px);overflow:auto;display:grid;align-content:start;gap:6px;padding:14px;border:1px solid var(--line);border-radius:14px;background:rgba(16,26,43,.9)}.docs-nav .eyebrow{margin:4px 8px 8px}.docs-nav button{padding:9px 10px;border:0;border-radius:8px;background:transparent;color:var(--muted);text-align:left;cursor:pointer;font:inherit}.docs-nav button:hover{background:rgba(98,230,167,.1);color:var(--text)}.docs #docs-content{display:grid;gap:16px}.docs #docs-content article{scroll-margin-top:18px;padding:24px;border:1px solid var(--line);border-radius:16px;background:rgba(16,26,43,.76);line-height:1.6}.docs #docs-content article h2{margin:0 0 12px}.docs #docs-content article h3{margin:20px 0 5px;color:var(--green);font-size:.86rem}.docs #docs-content article p{color:var(--muted)}.docs .doc-search{display:block;margin-top:18px}.docs .doc-search input{margin-top:8px}@media(max-width:800px){.docs-layout{grid-template-columns:1fr}.docs-nav{position:static;display:flex;flex-wrap:wrap}.docs-nav .eyebrow{width:100%}} +.docs-intro{padding:32px 34px}.docs-search-panel{max-width:880px;margin-top:24px;padding:18px 20px;border:1px solid var(--line);border-radius:14px;background:rgba(7,15,28,.42)}.docs-search-panel .doc-search{margin:0}.docs-search-panel .doc-search span{display:block;margin-bottom:8px;font-weight:750;color:var(--text)}.docs-search-panel small{display:block;margin-top:8px;color:var(--muted)}.docs #docs-content article ul{margin:10px 0 0;padding-left:22px;color:var(--muted)}.docs #docs-content article li{margin:8px 0} +.docs-intro{width:100%;box-sizing:border-box;text-align:center}.docs-intro>p:not(.eyebrow){margin-left:auto;margin-right:auto}.docs-search-panel{max-width:none;text-align:left}.docs-search-panel .doc-search input{width:100%;box-sizing:border-box} +/* Manual uses a full-width header, then a two-column reading layout. */ +.docs{display:block}.docs-intro{grid-column:1/-1}.docs-layout{width:100%} +.docs-intro{text-align:left}.docs-intro>p:not(.eyebrow){margin-left:0;margin-right:0}.docs-search-panel{margin-left:0;margin-right:0} +.inline-status{grid-column:1/-1;margin:0;color:var(--green);font-size:.8rem;font-weight:700} +.inline-status.status-success{color:var(--green)}.inline-status.status-warning{color:var(--warning)} +.encryption-grid{align-items:start}.encryption-toggle{align-items:flex-start;padding-top:28px}.encryption-toggle small{display:block;margin-top:5px;color:var(--muted);font-size:.72rem;line-height:1.4}.backup-settings .inline-status{margin-top:0} + +/* Keep backup encryption controls compact and consistent with the rest of the form. */ +.backup-settings .encryption-grid{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:0 18px} +.backup-settings .encryption-grid .backup-password-field, +.backup-settings .encryption-grid .encryption-toggle{grid-column:auto!important} +.backup-settings .encryption-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important} +.backup-settings .encryption-grid .backup-password-field, +.backup-settings .encryption-grid .encryption-toggle{grid-column:auto!important} +.backup-settings .encryption-grid .encryption-toggle{align-self:start!important;margin-top:16px!important;padding-top:0;height:44px;min-height:44px;box-sizing:border-box} +.backup-settings .encryption-grid .encryption-toggle span{line-height:1.35} +.backup-settings .form-section:first-child .form-grid>.check-control{align-self:start!important;margin-top:23px!important;height:44px;min-height:44px;box-sizing:border-box} +.backup-settings .form-section:last-of-type{border-bottom:0} +.backup-settings > .dialog-actions{border-top:0;padding-top:0} +@media(max-width:700px){.backup-settings .encryption-grid{grid-template-columns:1fr}} +.backup-settings + .callout + #backup-list{max-height:390px;overflow-y:auto;overscroll-behavior:contain} +.backup-password-field{position:relative}.backup-password-field input{padding-right:62px!important}.password-toggle{position:absolute;right:10px;top:36px;border:0;background:transparent;color:var(--green);font-size:.72rem;font-weight:750;cursor:pointer;padding:6px}.password-toggle:hover{color:var(--text)} +.backup-settings .backup-password-field input,.backup-settings .encryption-toggle-box{height:44px!important;min-height:44px!important;box-sizing:border-box}.backup-create-dialog select{height:44px;min-height:44px;margin-top:7px}.backup-dialog-help{display:block;margin-top:6px;color:var(--muted);line-height:1.45}.backup-create-dialog code{color:var(--green)} +.backup-create-dialog .dialog-heading>.icon-button{display:none} +#backup-settings-form .encryption-grid{display:grid!important;grid-template-columns:repeat(2,minmax(0,1fr))!important;align-items:start!important} +#backup-settings-form .encryption-grid>.backup-password-field, +#backup-settings-form .encryption-grid>.encryption-toggle{margin-top:16px!important;align-self:start!important} +#backup-settings-form .encryption-grid>.backup-password-field .field-label, +#backup-settings-form .encryption-grid>.encryption-toggle .field-label{height:20px;line-height:20px;margin-bottom:7px} +#backup-settings-form .encryption-grid>.backup-password-field input, +#backup-settings-form .encryption-grid>.encryption-toggle .encryption-toggle-box{height:44px!important;min-height:44px!important} +.backup-settings .form-grid>.check-control{height:44px!important;min-height:44px!important;align-self:start!important;margin-top:23px!important;box-sizing:border-box} +.backup-settings .encryption-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important;align-items:start} +.backup-settings .encryption-toggle{height:auto!important;min-height:0!important;margin-top:16px!important;padding:0!important;background:transparent!important;border:0!important;display:block!important} +.backup-settings .encryption-toggle .field-label{display:flex;align-items:center;height:20px;margin:0 0 7px;color:#c7d0de;font-size:.82rem;font-weight:650} +.backup-settings .encryption-toggle-box{display:flex;align-items:center;gap:10px;height:44px;min-height:44px;padding:0 12px;border:1px solid var(--line);border-radius:9px;background:#0b1525;box-sizing:border-box} +.backup-settings .encryption-toggle-box input{width:17px!important;height:17px!important;margin:0!important;flex:0 0 auto} +.backup-settings .encryption-toggle-box>span{color:var(--muted);font-size:.72rem;line-height:1.35} +/* Authoritative backup-form field layout: labels sit above equal-height controls. */ +#backup-settings-form .form-grid{align-items:start;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 18px} +#backup-settings-form .form-grid>label{margin-top:16px;min-width:0} +#backup-settings-form .form-grid>label.check-control{height:44px!important;min-height:44px!important;margin-top:39px!important;padding:0 12px!important;align-self:start!important;box-sizing:border-box} +#backup-settings-form .form-grid>label:not(.check-control) select, +#backup-settings-form .form-grid>label:not(.check-control) input{height:44px;min-height:44px;box-sizing:border-box} +#backup-settings-form .encryption-grid{grid-template-columns:repeat(2,minmax(0,1fr))!important} +#backup-settings-form .encryption-grid>.backup-password-field{margin-top:16px} +#backup-settings-form .encryption-grid>.encryption-toggle{margin-top:16px!important;height:auto!important;min-height:0!important;padding:0!important} +#backup-settings-form .encryption-toggle-box{height:44px;min-height:44px} +@media(max-width:900px){#backup-settings-form .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} +@media(max-width:600px){#backup-settings-form .form-grid,#backup-settings-form .encryption-grid{grid-template-columns:1fr!important}} +.login-card>.product-icon,.setup-card>.product-icon{width:100%;height:auto;max-height:92px;object-fit:contain;border-radius:0} +.brand-mark.small{width:40px;height:40px;border-radius:11px} +label.check-control:has(input[name="upstreamTlsInsecure"]){display:grid!important;grid-template-columns:auto minmax(0,1fr)!important;align-items:center!important;column-gap:10px!important;row-gap:3px!important} +label.check-control:has(input[name="upstreamTlsInsecure"]) span{grid-column:2!important;min-width:0} +label.check-control:has(input[name="upstreamTlsInsecure"]) small{grid-column:2!important;display:block!important;margin:0!important;line-height:1.35} +label.check-control:has(input[name="upstreamTlsInsecure"]){position:relative;height:44px!important;min-height:44px!important;margin-bottom:42px!important;display:flex!important;flex-wrap:nowrap!important;align-items:center!important} +label.check-control:has(input[name="upstreamTlsInsecure"]) span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +label.check-control:has(input[name="upstreamTlsInsecure"]) small{position:absolute;left:0;top:calc(100% + 7px);width:100%;padding:0!important;white-space:normal} +select{appearance:none!important;-webkit-appearance:none!important;background-repeat:no-repeat!important;background-position:right 14px center!important;background-size:16px!important} +:root[data-theme="light"] select{background-color:#fff;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23132033' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E")!important} +:root[data-theme="light"]{--green:#159a68} +:root[data-theme="light"] .toggle.on{background:#159a68} +:root[data-theme="light"] .status-dot.running{background:#159a68;box-shadow:0 0 0 4px rgba(21,154,104,.14)} +:root[data-theme="light"] .check-control input{accent-color:#159a68} +#certificate-list,#readiness-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:15px;background:var(--panel)} +#certificate-list{scrollbar-gutter:stable} +#readiness-list{scrollbar-gutter:stable} +#readiness-list .readiness-row .status-dot{width:8px;min-width:8px;height:8px;min-height:8px;flex:0 0 8px;margin:4px 0 0} +#readiness-list .readiness-row{gap:16px;padding:18px 20px} +#certificate-list .certificate-row summary,#readiness-list .readiness-row,#gateway-log-list .event-row,#access-log-list .event-row{align-items:center} +#gateway-log-list .event-row .activity-mark,#access-log-list .event-row .activity-mark{flex:0 0 20px} +#gateway-log-list .event-row>span:last-child,#access-log-list .event-row>span:last-child{display:grid;gap:3px;min-width:0} +.event-technical{margin-top:6px}.event-technical summary{padding:0;color:var(--blue);font-size:.7rem;cursor:pointer}.event-technical code{display:block;margin-top:5px;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--muted);font-size:.68rem} +#audit-list{max-height:min(52vh,620px);overflow:auto;border:1px solid var(--line);border-radius:15px;background:var(--panel);scrollbar-gutter:stable} +#audit-list .event-row{align-items:center;padding:18px 20px} +#audit-list .activity-mark{width:8px;height:8px;min-width:8px;border-radius:50%;padding:0;font-size:0;background:var(--green);box-shadow:0 0 0 4px rgba(98,230,167,.1)} +#audit-list .activity-mark.bad{background:var(--danger);box-shadow:0 0 0 4px rgba(255,113,133,.09)} +.diagnostic-section-heading{margin:20px 0 10px;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:15px 15px 0 0;background:var(--panel)} +.diagnostic-section-heading .eyebrow{margin-bottom:5px}.diagnostic-section-heading h2{margin:0;font-size:1.15rem}.diagnostic-section-heading p:last-child{margin:4px 0 0;font-size:.76rem} +.diagnostic-section-heading{margin-bottom:0} +.diagnostic-section-heading + .log-table-wrap{border-top:0;border-radius:0 0 15px 15px} +#certificate-list.diagnostic-list,#readiness-list.diagnostic-list,#gateway-log-list.diagnostic-list,#audit-list.diagnostic-list{border-radius:0 0 15px 15px} +.readiness-panel,.log-activity{background:transparent;border:0;padding:0} +.readiness-panel .panel-heading,.log-activity .panel-heading{margin:20px 0 0;padding:18px 20px;border:1px solid var(--line);border-bottom:0;border-radius:15px 15px 0 0;background:var(--panel)} +.log-activity .event-filters{margin:0;padding:0 20px 15px;border-left:1px solid var(--line);border-right:1px solid var(--line);background:var(--panel)} +.log-activity .diagnostic-list{border:1px solid var(--line);border-radius:0 0 15px 15px;background:var(--panel);overflow:auto} +.log-activity .event-row{gap:16px;padding:18px 20px;align-items:center} +.log-activity .event-row .status-dot{flex:0 0 8px} +.gateway-empty-state{display:grid;justify-items:center;gap:6px;padding:34px 20px;text-align:center;color:var(--muted)}.gateway-empty-state .status-dot{width:9px;height:9px;margin-bottom:3px;background:var(--muted);box-shadow:0 0 0 4px rgba(97,112,138,.1)}.gateway-empty-state strong{color:var(--text);font-size:.86rem}.gateway-empty-state small{font-size:.74rem} +.dialog-card select,.settings-form select{appearance:none!important;-webkit-appearance:none!important;background-repeat:no-repeat!important;background-position:right 14px center!important;background-size:16px!important} +:root[data-theme="light"] .dialog-card select,:root[data-theme="light"] .settings-form select{background-color:#fff;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none' stroke='%23132033' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4 6 4 4 4-4'/%3E%3C/svg%3E")!important} +#app .check-control:has(input[name="upstreamTlsInsecure"]){display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;column-gap:10px;row-gap:3px} +#app .check-control:has(input[name="upstreamTlsInsecure"]) span,#app .check-control:has(input[name="upstreamTlsInsecure"]) small{grid-column:2} +#app .check-control:has(input[name="upstreamTlsInsecure"]) small{display:block;margin:0;line-height:1.35} +#app .check-control:has(input[name="upstreamTlsInsecure"]){display:flex;flex-wrap:wrap;align-items:center;column-gap:10px;row-gap:3px} +#app .check-control:has(input[name="upstreamTlsInsecure"]) span{flex:1 1 0;min-width:0} +#app .check-control:has(input[name="upstreamTlsInsecure"]) small{flex:0 0 100%;padding-left:27px} +#settings-dialog .dialog-heading>.icon-button{display:none} +#icon-dialog .dialog-heading>.icon-button{display:none} +:root[data-theme="light"] .retention-run-status,:root[data-theme="light"] .retention-preview,:root[data-theme="light"] .retention-history{background:var(--panel)!important} +:root .retention-history-list{scrollbar-gutter:stable;scrollbar-color:var(--line) transparent}:root .retention-history-list::-webkit-scrollbar{width:10px}:root .retention-history-list::-webkit-scrollbar-track{background:transparent}:root .retention-history-list::-webkit-scrollbar-thumb{background:var(--line);border:3px solid transparent;border-radius:999px;background-clip:padding-box} +:root .site-icon,:root[data-theme="light"] .site-icon,:root .site-card.proxy .site-icon,:root[data-theme="light"] .site-card.proxy .site-icon{background:var(--panel2);color:var(--green);border:1px solid var(--line)} +:root .retention-load-more{display:block;margin:10px auto 0;padding:8px 14px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);color:var(--text);font-size:.72rem;font-weight:700}:root .retention-load-more:hover{border-color:var(--green);color:var(--green)} +:root .retention-load-more{display:none!important} +#create-dialog .dialog-heading .close-dialog,#settings-dialog .dialog-heading .close-dialog{display:none} +#app .site-card.access-card .site-icon{background:#193d38;color:var(--green)}:root[data-theme="light"] #app .site-card.access-card .site-icon{background:#e2f5ed;color:#138a5b} +#app .user-avatar{background:#193d38;color:var(--green)}:root[data-theme="light"] #app .user-avatar{background:#e2f5ed;color:#138a5b} +#app .user-avatar img{filter:grayscale(1) sepia(1) saturate(5) hue-rotate(95deg) brightness(1.1)} +#user-dialog .dialog-heading .close-dialog{display:none} +#app .user-card{position:relative;background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95));border:1px solid var(--line);border-radius:16px;box-shadow:0 14px 40px rgba(0,0,0,.14);transition:.2s}#app .user-card:hover{transform:translateY(-2px);border-color:#344b6e}:root[data-theme="light"] #app .user-card{background:linear-gradient(145deg,#fff,#f6f9fc);box-shadow:0 14px 40px rgba(34,54,80,.1)} +#app .user-card .user-avatar{background:var(--panel2)!important;color:var(--green)!important;border:1px solid var(--line)} +#app .user-avatar img{display:none!important} +#user-list .user-card{background:linear-gradient(145deg,rgba(20,33,54,.95),rgba(13,23,39,.95))!important;border:1px solid var(--line)!important;border-radius:16px!important;box-shadow:0 14px 40px rgba(0,0,0,.14)!important}#user-list .user-avatar{background:var(--panel2)!important;color:var(--green)!important;border:1px solid var(--line)!important}#user-list .user-avatar img{display:none!important}:root[data-theme="light"] #user-list .user-card{background:linear-gradient(145deg,#fff,#f6f9fc)!important;box-shadow:0 14px 40px rgba(34,54,80,.1)!important} +#password-dialog .dialog-heading .close-dialog{display:none} +#dashboard-jobs{margin:18px 0}.dashboard-jobs-panel .panel-heading{margin-bottom:8px}.dashboard-jobs-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px 18px}.dashboard-jobs-list .dashboard-list-item{padding:8px 0;border-top:0}.dashboard-jobs-list small{display:block}@media(max-width:900px){.dashboard-jobs-list{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.dashboard-jobs-list{grid-template-columns:1fr}} +#dashboard-view>[data-dashboard-health]{width:100%;margin-top:18px}.dashboard-columns{margin-top:18px}.dashboard-jobs-panel{min-height:100%}.dashboard-jobs-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:22px}.dashboard-jobs-list .dashboard-list-item{border-top:1px solid var(--line);padding:13px 3px}.dashboard-jobs-list .dashboard-list-item:nth-child(-n+2){border-top:0}@media(max-width:760px){.dashboard-jobs-list{grid-template-columns:1fr}.dashboard-jobs-list .dashboard-list-item:nth-child(2){border-top:1px solid var(--line)}} +#dashboard-view>[data-dashboard-health] .health-list{grid-template-columns:repeat(4,minmax(0,1fr));gap:0}#dashboard-view>[data-dashboard-health] .health-list>div{min-width:0;padding:12px 18px;border-top:0;border-left:1px solid var(--line)}#dashboard-view>[data-dashboard-health] .health-list>div:first-child{border-left:0;padding-left:3px}#dashboard-view>[data-dashboard-health] .health-list>div:last-child{padding-right:3px}@media(max-width:760px){#dashboard-view>[data-dashboard-health] .health-list{grid-template-columns:repeat(2,minmax(0,1fr))}#dashboard-view>[data-dashboard-health] .health-list>div{border-left:0;border-top:1px solid var(--line);padding:12px 3px}#dashboard-view>[data-dashboard-health] .health-list>div:nth-child(odd){border-left:0}#dashboard-view>[data-dashboard-health] .health-list>div:nth-child(-n+2){border-top:0}} +#dashboard-view>.dashboard-columns:not(.lower){align-items:start}#dashboard-view>.dashboard-columns:not(.lower)>.dashboard-panel{align-self:start;height:auto}#dashboard-view .dashboard-jobs-panel{min-height:0} +#dashboard-view>.dashboard-columns:not(.lower)>.dashboard-panel{align-self:stretch;height:auto}#dashboard-view>.dashboard-columns:not(.lower)>.dashboard-jobs-panel{min-height:100%} +#dashboard-view>.dashboard-columns:not(.lower){align-items:stretch}#dashboard-view>.dashboard-columns:not(.lower)>.dashboard-panel{height:auto;min-height:0}#dashboard-view>.dashboard-columns:not(.lower)>.dashboard-jobs-panel{min-height:0} +#dashboard-view>.dashboard-columns #dashboard-jobs{margin:0;align-self:start} +#dashboard-view>.dashboard-columns #dashboard-jobs{align-self:stretch;height:auto} +#dashboard-view .dashboard-jobs-list .dashboard-list-item:nth-child(even){border-left:1px solid var(--line);padding-left:22px}@media(max-width:760px){#dashboard-view .dashboard-jobs-list .dashboard-list-item:nth-child(even){border-left:0;padding-left:3px}} +#dashboard-view .dashboard-jobs-list{position:relative}#dashboard-view .dashboard-jobs-list::before,#dashboard-view .dashboard-jobs-list::after{content:"";position:absolute;background:var(--line);pointer-events:none}#dashboard-view .dashboard-jobs-list::before{left:50%;top:0;bottom:0;width:1px}#dashboard-view .dashboard-jobs-list::after{left:0;right:0;top:50%;height:1px}#dashboard-view .dashboard-jobs-list .dashboard-list-item:nth-child(even){border-left:0;padding-left:3px}@media(max-width:760px){#dashboard-view .dashboard-jobs-list::before{display:none}#dashboard-view .dashboard-jobs-list::after{top:50%}} +#dashboard-view .dashboard-jobs-list .dashboard-list-item{border-top:0!important} +#access-list.loading { visibility: hidden; } diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..284c764 --- /dev/null +++ b/src/server.js @@ -0,0 +1,1463 @@ +import crypto from "node:crypto"; +import dns from "node:dns/promises"; +import { execFile } from "node:child_process"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import http from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import AdmZip from "adm-zip"; +import express from "express"; +import multer from "multer"; +import { LOCAL_INSTANCE_ID, openStorage } from "./storage.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageMetadata = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); +const appVersion = process.env.APP_VERSION || packageMetadata.version; +const publicDir = path.join(__dirname, "public"); +const dataDir = path.resolve(process.env.DATA_DIR || "/data"); +const sitesDir = path.join(dataDir, "sites"); +const uploadDir = path.join(dataDir, ".uploads"); +const caddyDir = path.join(dataDir, "caddy"); +const iconsDir = path.join(dataDir, "icons"); +const logsDir = path.join(dataDir, "logs"); +const backupsDir = path.join(dataDir, "backups"); +const defaultSiteDir = path.join(dataDir, "default-site"); +const certificatesRoot = path.join(dataDir, "certificates"); +const customCertificatesDir = path.join(certificatesRoot, "custom"); +const managedCertificatesDir = path.join(certificatesRoot, "managed"); +const certificateExportsDir = path.join(certificatesRoot, "exports"); +const accessLogPath = path.join(logsDir, "access.json"); +const activityLogPath = path.join(logsDir, "activity.jsonl"); +const certificateDir = path.join(managedCertificatesDir, "certificates"); +const iconCatalogPath = path.join(iconsDir, "catalog.json"); +const caddyfilePath = path.join(caddyDir, "Caddyfile"); +const execFileAsync = promisify(execFile); +const scryptAsync = promisify(crypto.scrypt); +const adminPort = numberEnv("ADMIN_PORT", 8080); +const minPort = numberEnv("SITE_PORT_MIN", 9000); +const maxPort = numberEnv("SITE_PORT_MAX", 9099); +const adminUser = process.env.ADMIN_USERNAME || "admin"; +const adminPassword = process.env.ADMIN_PASSWORD || "change-this-password"; +const sessionSecret = process.env.SESSION_SECRET || crypto.createHash("sha256").update(`${adminUser}:${adminPassword}`).digest("hex"); +const scheduledBackupPassword = process.env.BACKUP_PASSWORD || ""; +const activeServers = new Map(); +let sites = []; +let proxies = []; +let users = []; +let redirects = []; +let accessLists = []; +let groups = []; +let settings = {}; +let gatewayError = null; +let lastGatewayReload = null; +let caddyVersion = "Unknown"; +const recentActivity = []; +const upstreamHealth = new Map(); +const certificateStatusCache = new Map(); +const loginAttempts = new Map(); +let currentAuditActor = null; +const probeFailures = { gateway: 0, http: 0, https: 0 }; +let iconCatalog = null; +let storage; + +function recordActivity(message, status = "ok") { + const entry = { message, status, at: new Date().toISOString() }; + recentActivity.unshift(entry); + recentActivity.splice(20); + try { storage?.recordActivity(message, status); } catch (error) { console.warn("Could not record SQLite activity event:", error.message); } + fsp.appendFile(activityLogPath, `${JSON.stringify(entry)}\n`).catch(() => {}); + try { storage?.recordAudit(message, status, null, currentAuditActor); } catch (error) { console.warn("Could not record SQLite audit event:", error.message); } +} + +async function directorySize(directory) { + let total = 0; + const entries = await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error)); + for (const entry of entries) { + const itemPath = path.join(directory, entry.name); + if (entry.isDirectory()) total += await directorySize(itemPath); + else if (entry.isFile()) total += (await fsp.stat(itemPath)).size; + } + return total; +} + +function numberEnv(name, fallback) { + const value = Number.parseInt(process.env[name] || "", 10); + return Number.isInteger(value) ? value : fallback; +} + +function safeEqual(a, b) { + const left = Buffer.from(String(a)); + const right = Buffer.from(String(b)); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +async function passwordRecord(password) { + const salt = crypto.randomBytes(16).toString("hex"); + const hash = await scryptAsync(String(password), salt, 64); + return { algorithm: "scrypt", salt, hash: hash.toString("hex") }; +} + +async function passwordMatches(password, record) { + if (!record?.salt || !record?.hash) return false; + const hash = await scryptAsync(String(password), record.salt, 64); + return safeEqual(hash.toString("hex"), record.hash); +} + +function publicUser(user) { + const { password, sessionVersion, ...safe } = user; + return safe; +} + +function activeAdministrators() { + return users.filter(user => user.role === "administrator" && user.status === "active"); +} + +function slugify(value) { + return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48); +} + +function sign(value) { + return crypto.createHmac("sha256", sessionSecret).update(value).digest("hex"); +} + +function cookieMap(header = "") { + return Object.fromEntries(header.split(";").map(v => v.trim().split("=").map(decodeURIComponent)).filter(v => v.length === 2)); +} + +function sessionUser(req) { + const token = cookieMap(req.headers.cookie).webserver_session; + if (!token) return null; + const [userId, expires, sessionVersion, signature] = token.split("."); + const user = users.find(item => item.id === userId && item.status === "active"); + if (!user || !expires || !sessionVersion || Number(expires) <= Date.now() || sessionVersion !== user.sessionVersion || !safeEqual(signature || "", sign(`${userId}.${expires}.${sessionVersion}`))) return null; + return user; +} + +const saveSites = async () => storage.saveCollection("sites", sites); +const saveProxies = async () => storage.saveCollection("proxies", proxies); +const saveUsers = async () => storage.saveCollection("users", users); +const saveGroups = async () => storage.saveCollection("groups", groups); +const saveRedirects = async () => storage.saveCollection("redirects", redirects); +const saveAccessLists = async () => storage.saveCollection("access_lists", accessLists); +const saveSettings = async () => storage.saveSettings(settings); + +async function clearDirectoryContents(directory) { + await fsp.mkdir(directory, { recursive: true }); + let lastError = null; + for (let attempt = 0; attempt < 4; attempt++) { + lastError = null; + for (const entry of await fsp.readdir(directory, { withFileTypes: true })) { + try { await fsp.rm(path.join(directory, entry.name), { recursive: true, force: true, maxRetries: 2, retryDelay: 100 }); } + catch (error) { lastError = error; } + } + if (!(await fsp.readdir(directory)).length) return; + await new Promise(resolve => setTimeout(resolve, 150 * (attempt + 1))); + } + if (lastError) throw lastError; + throw new Error(`Could not clear ${directory}: directory is not empty.`); +} + +async function loadSites() { + await Promise.all([fsp.mkdir(sitesDir, { recursive: true }), fsp.mkdir(uploadDir, { recursive: true }), fsp.mkdir(caddyDir, { recursive: true }), fsp.mkdir(iconsDir, { recursive: true }), fsp.mkdir(logsDir, { recursive: true }), fsp.mkdir(backupsDir, { recursive: true }), fsp.mkdir(defaultSiteDir, { recursive: true }), fsp.mkdir(customCertificatesDir, { recursive: true }), fsp.mkdir(managedCertificatesDir, { recursive: true }), fsp.mkdir(certificateExportsDir, { recursive: true })]); + if (!storage) storage = await openStorage(dataDir, backupsDir); + storage.humanizeGatewayErrors?.(); + if (storage.snapshot) { recordActivity(`Legacy JSON migrated to SQLite. Safety backup: ${storage.snapshot.filename}.`); storage.snapshot = null; } + sites = storage.loadCollection("sites").map(item => ({ ...item, healthEnabled: !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false") })); + proxies = storage.loadCollection("proxies").map(item => ({ ...item, healthEnabled: !(item.healthEnabled === false || String(item.healthEnabled).toLowerCase() === "false") })); + try { const legacyAccess = await readAccessLogs(5000); storage.recordAccessEvents(legacyAccess.map((entry, index) => ({ ...entry, source: `legacy-${entry.at || "unknown"}-${index}` }))); } catch (error) { console.warn("Could not import access logs into SQLite:", error.message); } + try { + const storedActivity = storage.listActivity(20); + if (storedActivity.length) recentActivity.push(...storedActivity); + else { + const lines = (await fsp.readFile(activityLogPath, "utf8")).trim().split("\n").slice(-20).reverse(); + const legacy = lines.filter(Boolean).map(line => JSON.parse(line)); + recentActivity.push(...legacy); + for (const entry of legacy.reverse()) storage.recordActivity(entry.message, entry.status); + } + } catch { /* Activity history starts empty on a new installation. */ } + users = storage.loadCollection("users"); + if (!users.length) { + const now = new Date().toISOString(); + users = [{ id: crypto.randomUUID(), username: adminUser.toLowerCase(), displayName: "Administrator", role: "administrator", status: "active", password: await passwordRecord(adminPassword), source: "bootstrap", setupRequired: true, sessionVersion: crypto.randomBytes(16).toString("hex"), createdAt: now, updatedAt: now, lastLoginAt: null }]; + await saveUsers(); + } + let usersChanged = false; + for (const user of users) { + if (user.setupRequired === undefined) { user.setupRequired = false; usersChanged = true; } + if (!user.sessionVersion) { user.sessionVersion = crypto.randomBytes(16).toString("hex"); usersChanged = true; } + } + if (usersChanged) await saveUsers(); + redirects = storage.loadCollection("redirects"); + accessLists = storage.loadCollection("access_lists"); + groups = storage.loadCollection("groups"); + const defaultSettings = { + defaultSite: { mode: "themed404", redirectUrl: "", redirectCode: 302, preservePath: true, title: "Route not found", message: "The gateway is responding, but this address has not been configured.", customHtml: "" }, + backups: { enabled: false, frequency: "daily", hour: 2, retention: 7, type: "configuration", includeLogs: false, encrypt: false, lastRunAt: null, lastStatus: null }, + certificateHealth: { warningDays: 30, criticalDays: 7, staleMinutes: 10 }, + logsRetention: { accessDays: 30, activityDays: 90, auditDays: 365, certificateDays: 365, securityDays: 365, pruningEnabled: false } + }; + const storedSettings = storage.loadSettings() || defaultSettings; + settings = { ...defaultSettings, ...storedSettings, defaultSite: { ...defaultSettings.defaultSite, ...(storedSettings.defaultSite || {}) }, backups: { ...defaultSettings.backups, ...(storedSettings.backups || {}) }, certificateHealth: { ...defaultSettings.certificateHealth, ...(storedSettings.certificateHealth || {}) }, logsRetention: { ...defaultSettings.logsRetention, ...(storedSettings.logsRetention || {}) } }; + await saveSettings(); +} + +function normalizeDomain(value) { + return String(value || "").trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, ""); +} +function normalizeDomains(primary, aliases = []) { + return [...new Set([primary, ...(Array.isArray(aliases) ? aliases : String(aliases || "").split(/[\n,]+/))].map(normalizeDomain).filter(Boolean))]; +} +function validateDomains(domains, exceptId) { + for (const domain of domains) { const error = validateDomain(domain, exceptId); if (error) return error; } + return null; +} + +function validateDomain(domain, exceptId) { + if (!domain) return null; + if (domain.length > 253 || !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)) return "Enter a valid public domain such as app.example.com."; + if ([...sites, ...proxies, ...redirects].some(item => normalizeDomains(item.domain, item.domains).includes(domain) && item.id !== exceptId)) return "That domain is already assigned."; + return null; +} + +function validateTarget(value) { + try { + const target = new URL(String(value || "")); + if (!["http:", "https:"].includes(target.protocol) || !target.hostname || (target.pathname && target.pathname !== "/") || target.search || target.hash) throw new Error(); + return target.toString().replace(/\/$/, ""); + } catch { + throw Object.assign(new Error("Target must be an HTTP or HTTPS address such as http://192.168.1.20:3000."), { status: 400 }); + } +} + +function cleanHeaders(value) { + if (!Array.isArray(value)) return []; + return value.slice(0, 30).map(item => ({ name: String(item.name || "").trim(), value: String(item.value || "").trim() })) + .filter(item => /^[A-Za-z0-9-]{1,80}$/.test(item.name) && item.value.length <= 500); +} + +function cleanLocations(value) { + if (!Array.isArray(value)) return []; + return value.slice(0, 20).map(item => { + const location = { path: String(item.path || "").trim(), target: validateTarget(item.target), stripPrefix: Boolean(item.stripPrefix), requestHeaders: cleanHeaders(item.requestHeaders), upstreamTlsServerName: String(item.upstreamTlsServerName || "").trim().slice(0, 253), upstreamTlsInsecure: Boolean(item.upstreamTlsInsecure) }; + if (!/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*\*?$/.test(location.path)) throw Object.assign(new Error("Custom Location paths must start with / and may end with *."), { status: 400 }); + return location; + }); +} + +function cleanCustomConfig(value) { + const config = String(value || "").trim(); + if (config.length > 20000) throw Object.assign(new Error("Custom Caddy configuration must be 20 KB or less."), { status: 400 }); + if (/(^|\n)\s*(?:\{|admin\b|storage\b|import\b|persist_config\b)/i.test(config)) throw Object.assign(new Error("Global blocks, imports, and Caddy administration settings are not allowed here."), { status: 400 }); + return config; +} + +function applyAdvancedSettings(item, body) { + if (body.upstreams !== undefined) { + if (!Array.isArray(body.upstreams) || body.upstreams.length > 10) throw Object.assign(new Error("Add up to 10 upstream targets."), { status: 400 }); + item.upstreams = body.upstreams.map(validateTarget); + } + if (body.accessListId !== undefined) item.accessListId = String(body.accessListId || ""); + if (body.compression !== undefined) item.compression = ["off", "gzip", "automatic"].includes(body.compression) ? body.compression : "automatic"; + if (body.hstsSubdomains !== undefined) item.hstsSubdomains = Boolean(body.hstsSubdomains); + if (body.requestHeaders !== undefined) item.requestHeaders = cleanHeaders(body.requestHeaders); + if (body.responseHeaders !== undefined) item.responseHeaders = cleanHeaders(body.responseHeaders); + if (body.upstreamTlsServerName !== undefined) item.upstreamTlsServerName = String(body.upstreamTlsServerName || "").trim().slice(0, 253); + if (body.upstreamTlsInsecure !== undefined) item.upstreamTlsInsecure = Boolean(body.upstreamTlsInsecure); + if (body.healthEnabled !== undefined) item.healthEnabled = body.healthEnabled === true || (typeof body.healthEnabled === "string" && body.healthEnabled.toLowerCase() === "true"); + if (body.healthPath !== undefined) item.healthPath = /^\//.test(body.healthPath || "") ? String(body.healthPath).slice(0, 500) : "/"; + if (body.healthMethod !== undefined) item.healthMethod = ["GET", "HEAD"].includes(body.healthMethod) ? body.healthMethod : "GET"; + if (body.healthExpected !== undefined) { + const expected = String(body.healthExpected || "200-499").trim().slice(0, 80); + if (!/^\d{3}(?:\s*-\s*\d{3})?(?:\s*,\s*\d{3}(?:\s*-\s*\d{3})?)*$/.test(expected)) throw Object.assign(new Error("Expected status must contain HTTP codes or ranges, such as 200,204 or 200-399."), { status: 400 }); + item.healthExpected = expected; + } + if (body.healthTimeoutSeconds !== undefined) item.healthTimeoutSeconds = Math.min(Math.max(Number(body.healthTimeoutSeconds) || 4, 1), 60); + if (body.healthRetries !== undefined) item.healthRetries = Math.min(Math.max(Number(body.healthRetries) || 0, 0), 3); + if (body.customConfig !== undefined) item.customConfig = cleanCustomConfig(body.customConfig); + if (body.locations !== undefined) item.locations = cleanLocations(body.locations); +} + +function expectedStatusMatches(status, specification = "200-499") { + return String(specification).split(",").some(part => { + const value = part.trim(); + if (/^\d{3}$/.test(value)) return status === Number(value); + const match = value.match(/^(\d{3})\s*-\s*(\d{3})$/); + return match ? status >= Number(match[1]) && status <= Number(match[2]) : false; + }); +} + +function caddySiteAddress(item) { + const domains = normalizeDomains(item.domain, item.domains); + return (item.tls === "http" ? domains.map(domain => `http://${domain}`) : domains).join(" "); +} + +function caddyQuote(value) { + return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", " ")}"`; +} + +function accessDirectives(accessListId) { + const list = accessLists.find(item => item.id === accessListId && item.enabled !== false); + if (!list) return []; + const output = []; + if (list.deniedNetworks?.length) output.push(` @blocked-${list.id} remote_ip ${list.deniedNetworks.join(" ")}`, ` abort @blocked-${list.id}`); + if (list.networks?.length) { + output.push(` @outside-${list.id} not remote_ip ${list.networks.join(" ")}`, ` abort @outside-${list.id}`); + } + if (list.credentials?.length || list.groups?.length) { + output.push(` @protected-${list.id} not path /_site-gateway/*`, ` forward_auth @protected-${list.id} 127.0.0.1:${adminPort} {`, ` uri /api/access-check?list=${list.id}`, " }", ` handle /_site-gateway/* {`, ` reverse_proxy 127.0.0.1:${adminPort}`, " }"); + } + return output; +} + +function commonHostDirectives(item) { + const output = [...accessDirectives(item.accessListId)]; + if (item.compression !== "off") output.push(item.compression === "gzip" ? " encode gzip" : " encode zstd gzip"); + for (const header of item.responseHeaders || []) output.push(` header ${header.name} ${caddyQuote(header.value)}`); + if (item.hsts && item.tls !== "http") output.push(` header Strict-Transport-Security ${caddyQuote(`max-age=31536000${item.hstsSubdomains ? "; includeSubDomains" : ""}`)}`); + if (item.tls === "internal") output.push(" tls internal"); + if (item.tls === "custom" && item.certificatePath && item.keyPath) output.push(` tls ${caddyQuote(item.certificatePath)} ${caddyQuote(item.keyPath)}`); + return output; +} + +function proxyBlock(target, item, indent = " ") { + const targets = Array.isArray(item.upstreams) && item.upstreams.length ? item.upstreams : [target]; + const output = [`${indent}reverse_proxy ${targets.join(" ")} {`]; + const timeout = Math.min(Math.max(Number(item.healthTimeoutSeconds) || 4, 1), 60); + const httpsUpstream = targets.length > 0 && targets.every(value => /^https:\/\//i.test(String(value).trim())); + if (httpsUpstream && (item.upstreamTlsServerName || item.upstreamTlsInsecure)) output.push(`${indent} transport http {`, ...(item.upstreamTlsServerName ? [`${indent} tls_server_name ${item.upstreamTlsServerName}`] : []), ...(item.upstreamTlsInsecure ? [`${indent} tls_insecure_skip_verify`] : []), `${indent} response_header_timeout ${timeout}s`, `${indent} }`); + for (const header of item.requestHeaders || []) output.push(`${indent} header_up ${header.name} ${caddyQuote(header.value)}`); + output.push(`${indent}}`); + return output; +} + +async function writeDefaultSitePage() { + const selected = settings.defaultSite || {}; + const title = String(selected.title || (selected.mode === "welcome" ? "Gateway ready" : "Route not found")).replace(/[<>]/g, ""); + const message = String(selected.message || "The gateway is responding, but this address has not been configured.").replace(/[<>]/g, ""); + const html = selected.mode === "custom" && selected.customHtml + ? String(selected.customHtml) + : `${title}
SG
Site Gateway

${title}

${message}

Host. Proxy. Secure.
`; + await fsp.writeFile(path.join(defaultSiteDir, "index.html"), html); +} + +function renderCaddyfile() { + const email = String(process.env.ACME_EMAIL || "").trim(); + const lines = ["{", " admin localhost:2019", " persist_config off", ` storage file_system ${managedCertificatesDir}`]; + if (email) lines.push(` email ${email}`); + const logging = [" log {", ` output file ${accessLogPath} {`, " roll_size 10mb", " roll_keep 5", " roll_keep_for 168h", " roll_uncompressed", " }", " format json", " }"]; + lines.push("}", "", ":80 {", ...logging); + const defaultSite = settings.defaultSite || {}; + if (defaultSite.mode === "abort") lines.push(" abort"); + else if (defaultSite.mode === "redirect" && defaultSite.redirectUrl) lines.push(` redir ${caddyQuote(`${defaultSite.redirectUrl}${defaultSite.preservePath ? "{uri}" : ""}`)} ${[301, 302, 307, 308].includes(Number(defaultSite.redirectCode)) ? Number(defaultSite.redirectCode) : 302}`); + else lines.push(` root * ${defaultSiteDir}`, " rewrite * /index.html", ` file_server {`, ` status ${defaultSite.mode === "welcome" ? 200 : 404}`, " }"); + lines.push("}"); + for (const site of sites.filter(item => item.enabled && item.domain)) { + lines.push("", `${caddySiteAddress(site)} {`, ...logging, ...commonHostDirectives(site), ` root * ${path.join(sitesDir, site.id)}`, " file_server"); + lines.push("}"); + } + for (const proxy of proxies.filter(item => item.enabled && item.domain)) { + lines.push("", `${caddySiteAddress(proxy)} {`, ...logging, ...commonHostDirectives(proxy)); + for (const location of proxy.locations || []) { + lines.push(` ${location.stripPrefix ? "handle_path" : "handle"} ${location.path} {`, ...proxyBlock(location.target, location, " "), " }"); + } + if ((proxy.locations || []).length) lines.push(" handle {", ...proxyBlock(proxy.target, proxy, " "), " }"); + else lines.push(...proxyBlock(proxy.target, proxy)); + if (proxy.customConfig) lines.push(" # Administrator-provided custom configuration", ...String(proxy.customConfig).split("\n").map(line => ` ${line}`)); + lines.push("}"); + } + for (const redirect of redirects.filter(item => item.enabled && item.domain)) { + const target = `${redirect.target}${redirect.preservePath ? "{uri}" : ""}`; + lines.push("", `${caddySiteAddress(redirect)} {`, ...logging, ...commonHostDirectives(redirect), ` redir ${caddyQuote(target)} ${redirect.code || 302}`, "}"); + } + return `${lines.join("\n")}\n`; +} + +async function syncCaddy() { + const nextPath = `${caddyfilePath}.next`; + const previous = await fsp.readFile(caddyfilePath, "utf8").catch(() => null); + const previousDefaultPage = await fsp.readFile(path.join(defaultSiteDir, "index.html")).catch(() => null); + await writeDefaultSitePage(); + await fsp.writeFile(nextPath, renderCaddyfile()); + try { + await execFileAsync("caddy", ["fmt", "--overwrite", nextPath]); + await execFileAsync("caddy", ["validate", "--config", nextPath, "--adapter", "caddyfile"]); + await fsp.rename(nextPath, caddyfilePath); + await execFileAsync("caddy", ["reload", "--config", caddyfilePath, "--adapter", "caddyfile"]); + gatewayError = null; + lastGatewayReload = new Date().toISOString(); + } catch (error) { + const rejectedReason = error.stderr || error.message; + let rollbackSucceeded = false; + await fsp.rm(nextPath, { force: true }); + if (previous !== null) { + await fsp.writeFile(caddyfilePath, previous); + rollbackSucceeded = await execFileAsync("caddy", ["reload", "--config", caddyfilePath, "--adapter", "caddyfile"]).then(() => true).catch(() => false); + } + if (previousDefaultPage !== null) await fsp.writeFile(path.join(defaultSiteDir, "index.html"), previousDefaultPage); + try { + sites = storage.loadCollection("sites"); proxies = storage.loadCollection("proxies"); redirects = storage.loadCollection("redirects"); accessLists = storage.loadCollection("access_lists"); settings = storage.loadSettings() || settings; + } catch { /* Startup may not have completed database initialization yet. */ } + gatewayError = rollbackSucceeded ? null : rejectedReason; + const friendly = /upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(rejectedReason) ? "This host forwards to HTTP, but Ignore upstream TLS certificate errors is enabled. Turn that option off or change the upstream to HTTPS." : /upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(rejectedReason) ? "This host forwards to HTTPS, but its upstream transport is configured for plain HTTP. Use HTTPS transport settings or change the upstream to HTTP." : /duplicate.*address|already.*site address/i.test(rejectedReason) ? "This hostname or address is already used by another host. Choose a unique hostname and port." : /dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(rejectedReason) ? "The upstream address could not be reached or is invalid. Check the hostname, IP address, and port." : /invalid hostname|host name.*invalid|malformed.*host/i.test(rejectedReason) ? "The hostname is not valid. Use a valid domain name without a protocol or path." : /unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(rejectedReason) ? "The gateway configuration contains an unsupported or malformed directive. Check the selected host settings." : /certificate|tls.*(config|handshake)|no certificate/i.test(rejectedReason) ? "The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings." : "The gateway rejected this configuration. Check the host, upstream address, and TLS settings."; + const detail = `${friendly}${rollbackSucceeded ? " The previous working configuration remains active." : ""}\nDetails: ${rejectedReason}`; + throw Object.assign(new Error(detail), { status: 400 }); + } +} + +function siteStatus(site) { + if (!site.enabled) return "disabled"; + if (site.domain && gatewayError) return "error"; + return activeServers.has(site.id) ? "running" : "error"; +} + +function publicSite(site) { + return { ...site, domains: normalizeDomains(site.domain, site.domains), status: siteStatus(site), url: `http://${site.host || "localhost"}:${site.port}`, upstream: upstreamHealth.get(site.id) || null }; +} + +function publicProxy(proxy, includeAdvanced = false) { + const { certificatePath, keyPath, ...safe } = proxy; + if (!includeAdvanced) { delete safe.customConfig; delete safe.requestHeaders; } + return { ...safe, domains: normalizeDomains(proxy.domain, proxy.domains), certificatePath: certificatePath ? "installed" : null, hasCustomCertificate: Boolean(certificatePath && keyPath), status: proxy.enabled ? (gatewayError ? "error" : "running") : "disabled", upstream: upstreamHealth.get(proxy.id) || null }; +} + +async function walkFiles(directory) { + const output = []; + for (const entry of await fsp.readdir(directory, { withFileTypes: true }).catch(error => error.code === "ENOENT" ? [] : Promise.reject(error))) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) output.push(...await walkFiles(fullPath)); + else if (entry.isFile()) output.push(fullPath); + } + return output; +} + +function certificateNames(certificate) { + const names = []; + for (const part of String(certificate.subjectAltName || "").split(/,\s*/)) if (part.startsWith("DNS:")) names.push(part.slice(4).toLowerCase()); + return names; +} + +async function certificateInventory() { + const configured = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))] + .filter(item => item.enabled && item.domain && item.tls !== "http"); + const configuredDomains = configured.flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain }))); + const parsed = []; + const certificateFiles = [...await walkFiles(certificateDir), ...await walkFiles(customCertificatesDir)]; + for (const filename of certificateFiles.filter(file => /\.(?:crt|pem)$/i.test(file))) { + try { + const certificate = new crypto.X509Certificate(await fsp.readFile(filename)); + const stat = await fsp.stat(filename); + parsed.push({ certificate, names: certificateNames(certificate), updatedAt: stat.mtime.toISOString(), filename, source: filename.startsWith(customCertificatesDir) ? "Custom upload" : "Caddy / ACME" }); + } catch { /* Ignore non-certificate PEM files and unreadable entries. */ } + } + const certificates = configuredDomains.map(item => { + const found = parsed.find(entry => entry.names.some(name => name === item.domain || (name.startsWith("*.") && item.domain.endsWith(name.slice(1))))); + if (!found) { + const customForRoute = item.tls === "custom" ? parsed.find(entry => entry.source === "Custom upload" && entry.filename.includes(item.id)) : null; + return { domain: item.domain, name: item.name, kind: item.kind, status: customForRoute ? "mismatch" : "pending", daysRemaining: null, expiresAt: null, issuer: null, updatedAt: customForRoute?.updatedAt || null, source: item.tls === "internal" ? "Caddy internal CA" : item.tls === "custom" ? "Custom upload" : "Caddy / ACME", mismatch: Boolean(customForRoute), coveredNames: customForRoute?.names || [] }; + } + const expiresAt = new Date(found.certificate.validTo); + const daysRemaining = Math.ceil((expiresAt.getTime() - Date.now()) / 86400000); + const warningDays = settings.certificateHealth?.warningDays || 30, criticalDays = settings.certificateHealth?.criticalDays || 7; + const status = daysRemaining <= 0 ? "expired" : daysRemaining <= criticalDays ? "critical" : daysRemaining <= warningDays ? "warning" : "healthy"; + return { domain: item.domain, name: item.name, kind: item.kind, status, daysRemaining, validFrom: new Date(found.certificate.validFrom).toISOString(), expiresAt: expiresAt.toISOString(), issuer: found.certificate.issuer, subject: found.certificate.subject, serialNumber: found.certificate.serialNumber, updatedAt: found.updatedAt, fingerprint: found.certificate.fingerprint256, coveredNames: found.names, source: item.tls === "internal" ? "Caddy internal CA" : found.source, mismatch: false }; + }); + for (const certificate of certificates) { const previous = certificateStatusCache.get(certificate.domain); if (previous && previous !== certificate.status) recordActivity(`Certificate status changed for ${certificate.domain}: ${previous} → ${certificate.status}.`, certificate.status === "healthy" ? "ok" : "error"); certificateStatusCache.set(certificate.domain, certificate.status); } + const latestError = recentActivity.find(item => item.status === "error" && /cert|tls|acme|caddy|gateway/i.test(item.message)) || null; + return { checkedAt: new Date().toISOString(), thresholds: settings.certificateHealth, latestError, summary: { total: certificates.length, healthy: certificates.filter(item => item.status === "healthy").length, within30Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 30 && item.daysRemaining > 0).length, within7Days: certificates.filter(item => item.daysRemaining != null && item.daysRemaining <= 7 && item.daysRemaining > 0).length, warning: certificates.filter(item => item.status === "warning").length, critical: certificates.filter(item => item.status === "critical").length, expired: certificates.filter(item => item.status === "expired").length, pending: certificates.filter(item => item.status === "pending").length, mismatch: certificates.filter(item => item.status === "mismatch").length }, certificates }; +} + +async function domainReadiness() { + const routes = [...sites.map(item => ({ ...item, kind: "Hosted site" })), ...proxies.map(item => ({ ...item, kind: "Proxy host" })), ...redirects.map(item => ({ ...item, kind: "Redirect host" }))].filter(item => item.enabled && item.domain).flatMap(item => normalizeDomains(item.domain, item.domains).map(domain => ({ ...item, domain }))); + const certs = await certificateInventory(); + const [httpResponding, httpsResponding] = await Promise.all([tcpProbe(80), tcpProbe(443)]); + return Promise.all(routes.map(async item => { + let addresses = [], dnsError = null; + try { addresses = [...new Set((await dns.lookup(item.domain, { all: true })).map(value => value.address))]; } catch (error) { dnsError = error.code || error.message; } + const certificate = certs.certificates.find(cert => cert.domain === item.domain) || null; + const upstream = item.kind === "Proxy host" ? upstreamHealth.get(item.id) || null : null; + return { id: item.id, domain: item.domain, name: item.name, kind: item.kind, dns: { healthy: addresses.length > 0, addresses, error: dnsError }, ports: { http: httpResponding, https: item.tls === "http" ? null : httpsResponding }, tls: item.tls === "http" ? { status: "not-configured" } : { status: certificate?.status || "pending" }, upstream }; + })); +} + +async function checkProxy(proxy) { + if (!proxy.enabled) { const result = { status: "disabled", checkedAt: new Date().toISOString(), history: [] }; upstreamHealth.set(proxy.id, result); return result; } + if (proxy.healthEnabled === false) { const result = { status: "unmonitored", checkedAt: null, history: [] }; upstreamHealth.set(proxy.id, result); return result; } + const started = performance.now(); + const attempts = Math.min(Math.max(Number(proxy.healthRetries) || 0, 0), 3) + 1; + let result; + for (let attempt = 0; attempt < attempts; attempt++) try { + const target = new URL(proxy.healthPath || "/", `${proxy.target}/`).toString(); + const response = await fetch(target, { method: proxy.healthMethod || "GET", redirect: "manual", signal: AbortSignal.timeout((proxy.healthTimeoutSeconds || 4) * 1000), headers: { "user-agent": "Site-Gateway-Health/1.0" } }); + await response.body?.cancel(); + const responseMs = Math.round(performance.now() - started); + const accepted = expectedStatusMatches(response.status, proxy.healthExpected); + result = { status: accepted ? "healthy" : "unhealthy", httpStatus: response.status, responseMs, attempts: attempt + 1, checkedAt: new Date().toISOString(), error: accepted ? null : `Expected ${proxy.healthExpected || "200-499"}; received HTTP ${response.status}` }; + if (accepted) break; + } catch (error) { + result = { status: "unhealthy", httpStatus: null, responseMs: Math.round(performance.now() - started), attempts: attempt + 1, checkedAt: new Date().toISOString(), error: error.name === "TimeoutError" ? `Timed out after ${proxy.healthTimeoutSeconds || 4} seconds` : error.message }; + } + const previous = upstreamHealth.get(proxy.id); + result.history = [{ status: result.status, responseMs: result.responseMs, httpStatus: result.httpStatus, checkedAt: result.checkedAt }, ...(previous?.history || [])].slice(0, 20); + upstreamHealth.set(proxy.id, result); + return result; +} + +async function checkAllProxies() { + await Promise.all([...proxies.map(checkProxy), ...sites.map(site => checkProxy({ ...site, target: `http://127.0.0.1:${site.port}`, healthPath: site.healthPath || "/", healthMethod: site.healthMethod || "GET", healthExpected: site.healthExpected || "200-499", healthTimeoutSeconds: site.healthTimeoutSeconds || 4, healthRetries: site.healthRetries || 0, healthEnabled: site.healthEnabled }))]); + return proxies.map(publicProxy); +} + +async function readAccessLogs(limit = 100, host = "") { + const files = (await fsp.readdir(logsDir).catch(() => [])).filter(name => name === "access.json" || name.startsWith("access.json.")).sort().reverse(); + const entries = []; + for (const name of files) { + const content = await fsp.readFile(path.join(logsDir, name), "utf8").catch(() => ""); + for (const line of content.trim().split("\n").reverse()) { + try { + const raw = JSON.parse(line); const request = raw.request || {}; const requestHost = String(request.host || "").split(":")[0]; + if (host && requestHost !== host) continue; + entries.push({ at: raw.ts ? new Date(raw.ts * 1000).toISOString() : null, host: requestHost, method: request.method, uri: request.uri, status: raw.status, size: raw.size, durationMs: Number.isFinite(raw.duration) ? Math.round(raw.duration * 1000) : null, remoteIp: request.remote_ip || null }); + if (entries.length >= limit) return entries; + } catch { /* Skip incomplete lines while Caddy writes. */ } + } + } + return entries; +} + +async function importAccessLogsToSqlite() { + if (!storage?.recordAccessEvents) return; + try { + const entries = await readAccessLogs(5000); + const events = entries.map(entry => ({ ...entry, source: crypto.createHash("sha1").update(JSON.stringify([entry.at, entry.host, entry.method, entry.uri, entry.status, entry.size, entry.durationMs, entry.remoteIp])).digest("hex") })); + storage.recordAccessEvents(events); + } catch (error) { console.warn("Could not import access logs into SQLite:", error.message); } +} + +function tcpProbe(port, timeoutMs = 1000) { + return new Promise(resolve => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + const finish = result => { socket.destroy(); resolve(result); }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} + +function stableProbe(name, responding) { + if (responding) { probeFailures[name] = 0; return { status: "ready", healthy: true, responding: true }; } + probeFailures[name] += 1; + return probeFailures[name] < 2 + ? { status: "checking", healthy: true, responding: false } + : { status: "error", healthy: false, responding: false }; +} + +async function loadIconCatalog() { + if (iconCatalog) return iconCatalog; + try { + const response = await fetch("https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/metadata.json", { signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error(`Icon catalogue returned ${response.status}.`); + const text = await response.text(); + if (text.length > 8 * 1024 * 1024) throw new Error("Icon catalogue is unexpectedly large."); + iconCatalog = JSON.parse(text); + await fsp.writeFile(iconCatalogPath, text); + } catch (error) { + try { iconCatalog = JSON.parse(await fsp.readFile(iconCatalogPath, "utf8")); } + catch { throw Object.assign(new Error("The icon catalogue is temporarily unavailable."), { status: 503 }); } + } + return iconCatalog; +} + +function iconLabel(slug) { + return slug.split("-").map(word => word ? word[0].toUpperCase() + word.slice(1) : "").join(" "); +} + +async function cacheIcon(slug) { + if (!/^[a-z0-9][a-z0-9-]{0,100}$/.test(slug)) throw Object.assign(new Error("Invalid icon selection."), { status: 400 }); + const catalog = await loadIconCatalog(); + const metadata = catalog[slug]; + if (!metadata) throw Object.assign(new Error("Icon not found."), { status: 404 }); + const response = await fetch(`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`, { signal: AbortSignal.timeout(7000) }); + if (!response.ok) throw Object.assign(new Error("The selected icon could not be downloaded."), { status: 502 }); + const svg = await response.text(); + if (svg.length > 512 * 1024 || !/]/i.test(svg) || /<(?:script|foreignObject)\b|\son\w+\s*=|(?:href|xlink:href)\s*=\s*["'](?:https?:|\/\/)/i.test(svg)) { + throw Object.assign(new Error("The selected icon did not pass safety validation."), { status: 400 }); + } + const filename = `${slug}.svg`; + await fsp.writeFile(path.join(iconsDir, filename), svg); + return `/site-icons/${filename}`; +} + +async function dashboardSnapshot() { + const hosted = sites.map(publicSite); + const proxyHosts = proxies.map(publicProxy); + const certificates = await certificateInventory(); + const tlsDomains = [...sites, ...proxies].filter(item => item.enabled && item.domain && item.tls !== "http").length; + const [storageWritable, gatewayResponding, httpResponding, httpsResponding] = await Promise.all([ + fsp.access(dataDir, fs.constants.R_OK | fs.constants.W_OK).then(() => true).catch(() => false), + tcpProbe(2019), + tcpProbe(80), + tlsDomains ? tcpProbe(443) : Promise.resolve(false) + ]); + let gatewayProbe = stableProbe("gateway", gatewayResponding); + if (gatewayError) gatewayProbe = { status: "error", healthy: false, responding: gatewayResponding }; + const httpProbe = stableProbe("http", httpResponding); + const httpsProbe = tlsDomains ? stableProbe("https", httpsResponding) : { status: "unconfigured", healthy: true, responding: false }; + const attention = []; + if (gatewayError) attention.push({ kind: "gateway", name: "Gateway configuration", message: "Caddy rejected the current configuration." }); + if (gatewayProbe.status === "error" && !gatewayResponding) attention.push({ kind: "gateway", name: "Caddy gateway", message: "The Caddy administration endpoint is not responding." }); + if (httpProbe.status === "error") attention.push({ kind: "http", name: "HTTP · Port 80", message: "Port 80 is not accepting connections inside the container." }); + if (httpsProbe.status === "error") attention.push({ kind: "https", name: "HTTPS · Port 443", message: "TLS domains are enabled but port 443 is not accepting connections." }); + if (!storageWritable) attention.push({ kind: "storage", name: "Persistent storage", message: "The data directory is not readable and writable." }); + for (const site of hosted.filter(item => item.status === "error")) attention.push({ kind: "hosted", name: site.name, message: `Hosted site is not responding on port ${site.port}.` }); + for (const proxy of proxyHosts.filter(item => item.status === "error")) attention.push({ kind: "proxy", name: proxy.name, message: "Proxy route needs attention." }); + for (const proxy of proxyHosts.filter(item => item.enabled && item.upstream?.status === "unhealthy")) attention.push({ kind: "upstream", name: proxy.name, message: `Upstream is unavailable${proxy.upstream.error ? ` · ${proxy.upstream.error}` : ""}.` }); + for (const certificate of certificates.certificates.filter(item => ["warning", "critical", "expired", "mismatch"].includes(item.status))) attention.push({ kind: "certificate", target: "certificates", name: certificate.domain, message: certificate.status === "expired" ? "Certificate has expired." : certificate.status === "mismatch" ? "The uploaded certificate does not cover this domain." : `Certificate expires in ${certificate.daysRemaining} day${certificate.daysRemaining === 1 ? "" : "s"}.` }); + const disk = await fsp.statfs(dataDir).catch(() => null); + const databaseIntegrity = storage.integrity(); + return { + checkedAt: new Date().toISOString(), + gateway: { ...gatewayProbe, lastReload: lastGatewayReload }, + services: { + http: { ...httpProbe, port: 80 }, + https: { ...httpsProbe, port: 443, activeDomains: tlsDomains }, + storage: { status: storageWritable ? "ready" : "error", healthy: storageWritable, path: dataDir } + }, + hosted: { total: hosted.length, running: hosted.filter(item => item.status === "running").length, disabled: hosted.filter(item => item.status === "disabled").length, errors: hosted.filter(item => item.status === "error").length }, + proxies: { total: proxyHosts.length, running: proxyHosts.filter(item => item.status === "running").length, disabled: proxyHosts.filter(item => item.status === "disabled").length, errors: proxyHosts.filter(item => item.status === "error").length }, + tlsDomains, + certificates: certificates.summary, + upstreams: { total: proxyHosts.filter(item => item.enabled).length, healthy: proxyHosts.filter(item => item.upstream?.status === "healthy").length, unhealthy: proxyHosts.filter(item => item.upstream?.status === "unhealthy").length }, + attention, + system: { + uptimeSeconds: Math.floor(process.uptime()), + memoryBytes: process.memoryUsage().rss, + dataBytes: await directorySize(dataDir), + diskFreeBytes: disk ? disk.bavail * disk.bsize : null, + diskTotalBytes: disk ? disk.blocks * disk.bsize : null, + appVersion, + caddyVersion, + nodeVersion: process.version, + databaseEngine: "SQLite", + databaseStatus: databaseIntegrity.length === 1 && databaseIntegrity[0] === "ok" ? "Healthy" : "Needs attention", + databaseBytes: (await fsp.stat(storage.databasePath).catch(() => null))?.size || 0, + jobs: [{ name: "Upstream checks", enabled: true, schedule: "60s" }, { name: "Scheduled backups", enabled: Boolean(settings.backups?.enabled), schedule: settings.backups?.enabled ? settings.backups.frequency : "off" }, { name: "Log pruning", enabled: Boolean(settings.logsRetention?.pruningEnabled), schedule: settings.logsRetention?.pruningEnabled ? "15m" : "off" }, { name: "Access-log import", enabled: true, schedule: "30s" }] + }, + activity: recentActivity + }; +} + +async function startSite(site) { + if (!site.enabled || activeServers.has(site.id)) return; + const root = path.join(sitesDir, site.id); + const app = express(); + app.disable("x-powered-by"); + app.use(express.static(root, { extensions: ["html"], index: "index.html", fallthrough: true })); + app.use((req, res) => res.status(404).sendFile(path.join(publicDir, "site-404.html"))); + const server = http.createServer(app); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(site.port, "0.0.0.0", resolve); + }); + activeServers.set(site.id, server); + console.log(`Serving ${site.name} on port ${site.port}`); +} + +async function stopSite(id) { + const server = activeServers.get(id); + if (!server) return; + await new Promise(resolve => server.close(resolve)); + activeServers.delete(id); +} + +async function restartSite(site) { + await stopSite(site.id); + if (site.enabled) await startSite(site); +} + +function validatePort(port, exceptId) { + if (!Number.isInteger(port) || port < minPort || port > maxPort) return `Port must be between ${minPort} and ${maxPort}.`; + if (sites.some(site => site.port === port && site.id !== exceptId)) return "That port is already assigned."; + return null; +} + +async function installUpload(site, file) { + const destination = path.join(sitesDir, site.id); + const staging = `${destination}.staging-${Date.now()}`; + await fsp.mkdir(staging, { recursive: true }); + try { + if (file.originalname.toLowerCase().endsWith(".zip")) { + const zip = new AdmZip(file.path); + for (const entry of zip.getEntries()) { + const normalized = path.normalize(entry.entryName).replace(/^(\.\.(\/|\\|$))+/, ""); + const target = path.resolve(staging, normalized); + if (!target.startsWith(`${path.resolve(staging)}${path.sep}`) && target !== path.resolve(staging)) throw new Error("Unsafe path in ZIP file."); + if (entry.isDirectory) await fsp.mkdir(target, { recursive: true }); + else { + await fsp.mkdir(path.dirname(target), { recursive: true }); + await fsp.writeFile(target, entry.getData()); + } + } + const children = await fsp.readdir(staging, { withFileTypes: true }); + if (children.length === 1 && children[0].isDirectory()) { + const nested = path.join(staging, children[0].name); + const nestedChildren = await fsp.readdir(nested); + for (const child of nestedChildren) await fsp.rename(path.join(nested, child), path.join(staging, child)); + await fsp.rmdir(nested); + } + } else { + await fsp.copyFile(file.path, path.join(staging, "index.html")); + } + await fsp.access(path.join(staging, "index.html")); + await fsp.rm(destination, { recursive: true, force: true }); + await fsp.rename(staging, destination); + } finally { + await fsp.rm(file.path, { force: true }); + await fsp.rm(staging, { recursive: true, force: true }); + } +} + +const portableCollections = { "sites.json": () => sites, "proxies.json": () => proxies, "redirects.json": () => redirects, "access-lists.json": () => accessLists, "users.json": () => users, "groups.json": () => groups, "settings.json": () => settings }; + +async function protectBackup(buffer, password) { + if (!password) return buffer; + const salt = crypto.randomBytes(16), iv = crypto.randomBytes(12), key = await scryptAsync(password, salt, 32), cipher = crypto.createCipheriv("aes-256-gcm", key, iv), encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); + return Buffer.concat([Buffer.from("SGBK1"), salt, iv, cipher.getAuthTag(), encrypted]); +} + +async function openBackup(filename, password = "") { + let buffer = await fsp.readFile(filename), encrypted = false; + if (buffer.subarray(0, 5).toString() === "SGBK1") { + encrypted = true; if (!password) throw Object.assign(new Error("This backup is encrypted. Enter its password."), { status: 400 }); + try { const salt = buffer.subarray(5, 21), iv = buffer.subarray(21, 33), tag = buffer.subarray(33, 49), key = await scryptAsync(password, salt, 32), decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); buffer = Buffer.concat([decipher.update(buffer.subarray(49)), decipher.final()]); } + catch { throw Object.assign(new Error("The backup password is incorrect or the file is damaged."), { status: 400 }); } + } + return { zip: new AdmZip(buffer), encrypted }; +} + +async function createBackup(type = "configuration", includeLogs = false, prefix = "site-gateway-backup", password = "") { + const safeType = type === "complete" ? "complete" : "configuration"; + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `${prefix}-${stamp}.sgbackup`; + const destination = path.join(backupsDir, filename); + const zip = new AdmZip(); + const manifest = { format: 2, product: "Site Gateway", appVersion, database: "sqlite", schemaVersion: 1, instanceId: LOCAL_INSTANCE_ID, createdAt: new Date().toISOString(), type: safeType, includeLogs: Boolean(includeLogs), encrypted: Boolean(password), files: [] }; + const databaseSnapshot = path.join(uploadDir, `database-${crypto.randomUUID()}.sqlite`); + storage.backupTo(databaseSnapshot); zip.addLocalFile(databaseSnapshot, "database", "site-gateway.sqlite"); await fsp.rm(databaseSnapshot, { force: true }); + for (const [name, getter] of Object.entries(portableCollections)) zip.addFile(`portable-json/${name}`, Buffer.from(JSON.stringify(getter(), null, 2))); + if (safeType === "complete") { + for (const [directory, archivePath] of [[sitesDir, "sites"], [iconsDir, "icons"], [defaultSiteDir, "default-site"], [certificatesRoot, "certificates"]]) { + if (fs.existsSync(directory)) zip.addLocalFolder(directory, archivePath); + } + } + if (includeLogs && fs.existsSync(logsDir)) zip.addLocalFolder(logsDir, "logs"); + 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))); + await fsp.writeFile(destination, await protectBackup(zip.toBuffer(), password)); + recordActivity(`${safeType === "complete" ? "Complete" : "Configuration"} backup created.`); + return { filename, path: destination, ...manifest, size: (await fsp.stat(destination)).size }; +} + +async function listBackups() { + const names = (await fsp.readdir(backupsDir)).filter(name => name.endsWith(".sgbackup")); + return Promise.all(names.map(async filename => { + const stat = await fsp.stat(path.join(backupsDir, filename)); + let manifest = {}; const header = Buffer.alloc(5); const handle = await fsp.open(path.join(backupsDir, filename), "r"); await handle.read(header, 0, 5, 0); await handle.close(); const encrypted = header.toString() === "SGBK1"; + if (!encrypted) try { manifest = JSON.parse(new AdmZip(path.join(backupsDir, filename)).readAsText("manifest.json")); } catch { /* Report unreadable archive in UI. */ } + return { filename, size: stat.size, createdAt: manifest.createdAt || stat.mtime.toISOString(), type: encrypted ? "encrypted" : manifest.type || "unknown", appVersion: encrypted ? "protected" : manifest.appVersion || "unknown", valid: encrypted || Boolean(manifest.format), encrypted }; + })).then(items => items.sort((a, b) => b.createdAt.localeCompare(a.createdAt))); +} + +async function restoreBackup(filename, password = "", createSafetyBackup = true) { + const source = path.resolve(backupsDir, filename); + if (!source.startsWith(`${backupsDir}${path.sep}`) || !filename.endsWith(".sgbackup")) throw Object.assign(new Error("Invalid backup selection."), { status: 400 }); + const { zip } = await openBackup(source, password); const manifest = JSON.parse(zip.readAsText("manifest.json") || "null"); + if (!manifest || manifest.product !== "Site Gateway" || ![1,2].includes(manifest.format)) throw Object.assign(new Error("This is not a supported Site Gateway backup."), { status: 400 }); + for (const [name, expected] of Object.entries(manifest.checksums || {})) { + const entry = zip.getEntry(name); if (!entry || crypto.createHash("sha256").update(entry.getData()).digest("hex") !== expected) throw Object.assign(new Error(`Backup integrity check failed for ${name}.`), { status: 400 }); + } + const safetyBackup = createSafetyBackup ? await createBackup("complete", true, "pre-restore") : null; + const staging = path.join(uploadDir, `restore-${crypto.randomUUID()}`); await fsp.mkdir(staging, { recursive: true }); + try { + for (const entry of zip.getEntries()) { + if (entry.entryName === "manifest.json") continue; + const target = path.resolve(staging, entry.entryName); + if (!target.startsWith(`${staging}${path.sep}`)) throw Object.assign(new Error("Unsafe path in backup."), { status: 400 }); + if (entry.isDirectory) await fsp.mkdir(target, { recursive: true }); else { await fsp.mkdir(path.dirname(target), { recursive: true }); await fsp.writeFile(target, entry.getData()); } + } + const restoredDatabase = path.join(staging, "database", "site-gateway.sqlite"); + if (fs.existsSync(restoredDatabase)) { + const candidate = new (await import("node:sqlite")).DatabaseSync(restoredDatabase, { readOnly: true }); const check = candidate.prepare("PRAGMA integrity_check").get(); candidate.close(); + if (Object.values(check)[0] !== "ok") throw Object.assign(new Error("The restored SQLite database failed its integrity check."), { status: 400 }); + const activeDatabasePath = storage.databasePath; storage.close(); + await Promise.all([fsp.rm(`${activeDatabasePath}-wal`, { force: true }), fsp.rm(`${activeDatabasePath}-shm`, { force: true })]); + await fsp.copyFile(restoredDatabase, activeDatabasePath); storage = await openStorage(dataDir, backupsDir); + } else { + const legacyRoot = fs.existsSync(path.join(staging, "portable-json")) ? path.join(staging, "portable-json") : fs.existsSync(path.join(staging, "legacy-json")) ? path.join(staging, "legacy-json") : path.join(staging, "config"); + storage.saveCollection("sites", []); storage.saveCollection("proxies", []); storage.saveCollection("redirects", []); + for (const [name, kind] of Object.entries({ "access-lists.json":"access_lists", "sites.json":"sites", "proxies.json":"proxies", "redirects.json":"redirects", "users.json":"users" })) { const candidate = path.join(legacyRoot, name); if (fs.existsSync(candidate)) storage.saveCollection(kind, JSON.parse(await fsp.readFile(candidate, "utf8"))); } + const settingsCandidate = path.join(legacyRoot, "settings.json"); if (fs.existsSync(settingsCandidate)) storage.saveSettings(JSON.parse(await fsp.readFile(settingsCandidate, "utf8"))); + } + if (manifest.type === "complete") for (const name of ["sites", "icons", "default-site", "certificates"]) { + const candidate = path.join(staging, name); if (!fs.existsSync(candidate)) continue; + const destination = path.join(dataDir, name); await fsp.rm(destination, { recursive: true, force: true }); await fsp.cp(candidate, destination, { recursive: true }); + } + if (manifest.type === "complete" && fs.existsSync(path.join(staging, "custom-certificates"))) { + await fsp.mkdir(customCertificatesDir, { recursive: true }); await fsp.cp(path.join(staging, "custom-certificates"), customCertificatesDir, { recursive: true }); + } + await Promise.all([...activeServers.keys()].map(stopSite)); sites = []; proxies = []; users = []; redirects = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); + if (manifest.type === "complete") for (const site of sites) { const contentRoot = path.join(sitesDir, site.id); if (!fs.existsSync(path.join(contentRoot, "index.html"))) throw new Error(`Restored hosted site “${site.name || site.id}” is missing index.html.`); } + for (const site of sites.filter(item => item.enabled)) await startSite(site); + await syncCaddy(); recordActivity(`Backup ${filename} restored.`); + } catch (error) { + if (safetyBackup) { + try { await restoreBackup(safetyBackup.filename, "", false); recordActivity(`Restore of ${filename} failed; the pre-restore state was recovered.`, "error"); } + catch (rollbackError) { error.message = `${error.message} Automatic rollback also failed: ${rollbackError.message}`; } + } + throw error; + } finally { await fsp.rm(staging, { recursive: true, force: true }); } + return manifest; +} + +await loadSites(); +try { + const result = await execFileAsync("caddy", ["version"]); + caddyVersion = result.stdout.trim().split(/\s+/)[0] || "Unknown"; +} catch (error) { + console.warn("Could not detect Caddy version:", error.message); +} +for (const site of sites.filter(item => item.enabled)) { + try { await startSite(site); } catch (error) { console.error(`Could not start ${site.name}:`, error.message); } +} +for (let attempt = 0; attempt < 10; attempt++) { + try { await syncCaddy(); break; } + catch (error) { + if (attempt === 9) console.error(error.message); + else await new Promise(resolve => setTimeout(resolve, 500)); + } +} + +const app = express(); +const upload = multer({ dest: uploadDir, limits: { fileSize: 250 * 1024 * 1024, files: 1 } }); +const certificateUpload = multer({ dest: uploadDir, limits: { fileSize: 5 * 1024 * 1024, files: 2 } }); +const iconUpload = multer({ dest: uploadDir, limits: { fileSize: 2 * 1024 * 1024, files: 1 } }); +app.disable("x-powered-by"); +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); +app.use(express.static(publicDir)); +app.use("/site-icons", express.static(iconsDir, { immutable: true, maxAge: "30d", setHeaders: res => res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") })); + +app.get("/api/session", (req, res) => { + const user = sessionUser(req); + res.json({ authenticated: Boolean(user), setupRequired: Boolean(user?.setupRequired), installationSetupPending: users.some(item => item.setupRequired), user: user ? publicUser(user) : null, username: user?.username || null }); +}); +app.post("/api/login", async (req, res, next) => { + try { + const key = req.ip || req.socket.remoteAddress || "unknown"; + const attempt = loginAttempts.get(key) || { count: 0, resetAt: Date.now() + 15 * 60 * 1000 }; + if (attempt.resetAt <= Date.now()) { attempt.count = 0; attempt.resetAt = Date.now() + 15 * 60 * 1000; } + if (attempt.count >= 8) { recordActivity(`Security: sign-in rate limit reached for ${key}.`, "error"); return res.status(429).json({ error: "Too many sign-in attempts. Try again in 15 minutes." }); } + const username = String(req.body.username || "").trim().toLowerCase(); + const user = users.find(item => item.username === username); + if (!user || user.status !== "active" || !await passwordMatches(req.body.password || "", user.password)) { + attempt.count += 1; loginAttempts.set(key, attempt); recordActivity(`Security: failed sign-in attempt for ${username || "unknown user"}.`, "error"); + return res.status(401).json({ error: "Incorrect username or password." }); + } + loginAttempts.delete(key); + user.lastLoginAt = new Date().toISOString(); user.updatedAt = user.lastLoginAt; await saveUsers(); + if (!user.sessionVersion) user.sessionVersion = crypto.randomBytes(16).toString("hex"); + const expires = String(Date.now() + 12 * 60 * 60 * 1000); + const value = `${user.id}.${expires}.${user.sessionVersion}`; + res.setHeader("Set-Cookie", `webserver_session=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200`); + res.json({ ok: true, user: publicUser(user) }); + } catch (error) { next(error); } +}); +app.post("/api/logout", (req, res) => { + res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); + res.json({ ok: true }); +}); +function accessSession(req, listId) { + const token = cookieMap(req.headers.cookie).site_gateway_access; if (!token) return null; + const [storedList, username, expires, signature] = token.split("."); + if (storedList !== listId || Number(expires) <= Date.now() || !safeEqual(signature || "", sign(`${storedList}.${username}.${expires}`))) return null; + return username; +} +function accessUserAllowed(list, username) { if (list.credentials?.some(item => item.username === username)) return true; return (list.groups || []).some(groupId => { const group = groups.find(item => item.id === groupId && item.enabled !== false); return Boolean(group?.members?.some(userId => users.some(user => user.id === userId && user.status === "active" && user.username === username))); }); } +app.get("/api/access-check", (req, res) => { + const listId = String(req.query.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false); + if (!list || !list.credentials?.length) return res.status(204).end(); + const username = accessSession(req, listId); if (username && accessUserAllowed(list, username)) { res.setHeader("X-Site-Gateway-User", username); return res.status(204).end(); } + const original = String(req.headers["x-forwarded-uri"] || "/"); const safeReturn = original.startsWith("/") && !original.startsWith("//") ? original : "/"; + res.redirect(302, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}`); +}); +app.get("/_site-gateway/login", (req, res) => { + const listId = String(req.query.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false); + if (!list) return res.status(404).send("Access policy not found."); const safeReturn = String(req.query.return || "/").startsWith("/") ? String(req.query.return || "/") : "/"; + res.type("html").send(`Sign in · Site Gateway
SG
Protected by Site Gateway

Sign in to continue

This service uses the ${String(list.name).replace(/[<>]/g, "")} access policy.

${req.query.error ? '

That username or password was not accepted.

' : ""}
`); +}); +app.post("/_site-gateway/login", async (req, res, next) => { + try { + const listId = String(req.body.list || ""), list = accessLists.find(item => item.id === listId && item.enabled !== false), username = String(req.body.username || "").trim(); const credential = list?.credentials?.find(item => item.username === username) || ((list && accessUserAllowed(list, username)) ? users.find(user => user.username === username && user.status === "active") : null); + const safeReturn = String(req.body.return || "/").startsWith("/") && !String(req.body.return).startsWith("//") ? String(req.body.return) : "/"; + if (!credential?.password || !await passwordMatches(req.body.password || "", credential.password)) return res.redirect(303, `/_site-gateway/login?list=${encodeURIComponent(listId)}&return=${encodeURIComponent(safeReturn)}&error=1`); + const expires = String(Date.now() + 12 * 60 * 60 * 1000), value = `${listId}.${username}.${expires}`; const secure = String(req.headers["x-forwarded-proto"] || "").includes("https") ? "; Secure" : ""; + res.setHeader("Set-Cookie", `site_gateway_access=${value}.${sign(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200${secure}`); res.redirect(303, safeReturn); + } catch (error) { next(error); } +}); +app.use("/api", (req, res, next) => { + const user = sessionUser(req); + if (!user) return res.status(401).json({ error: "Please sign in." }); + req.user = user; + next(); +}); +app.post("/api/setup/admin", async (req, res, next) => { + try { + if (!req.user.setupRequired || req.user.source !== "bootstrap" || req.user.role !== "administrator") return res.status(409).json({ error: "Initial administrator setup has already been completed." }); + const username = String(req.body.username || "").trim().toLowerCase(); + const displayName = String(req.body.displayName || "").trim(); + const password = String(req.body.password || ""); + const confirmation = String(req.body.confirmPassword || ""); + if (!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(username)) return res.status(400).json({ error: "Username must be 3–64 characters using letters, numbers, periods, hyphens, or underscores." }); + if (users.some(user => user.id !== req.user.id && user.username === username)) return res.status(409).json({ error: "That username already exists." }); + if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." }); + if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." }); + if (!safeEqual(password, confirmation)) return res.status(400).json({ error: "The passwords do not match." }); + req.user.username = username; req.user.displayName = displayName; req.user.password = await passwordRecord(password); + req.user.source = "local"; req.user.setupRequired = false; req.user.sessionVersion = crypto.randomBytes(16).toString("hex"); req.user.updatedAt = new Date().toISOString(); + await saveUsers(); recordActivity(`Initial administrator setup completed for “${username}”.`); + res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); + res.json({ ok: true }); + } catch (error) { next(error); } +}); +app.use("/api", (req, res, next) => { currentAuditActor = req.user?.id || null; return req.user.setupRequired ? res.status(428).json({ error: "Complete the initial administrator setup before continuing." }) : next(); }); +app.use("/api", (req, res, next) => { if (req.method === "GET" || req.user.role === "administrator") return next(); const operational = /^\/(sites|proxies|redirects|access-lists)(\/|$)/.test(req.path); if (req.user.role === "standard" && operational) return next(); return res.status(403).json({ error: "Administrator access is required for this action." }); }); +app.get("/api/config", (req, res) => res.json({ version: appVersion, minPort, maxPort, adminPort, storage: { engine: "sqlite", databasePath: storage.databasePath, instanceId: LOCAL_INSTANCE_ID, backupsPath: backupsDir, certificatesPath: certificatesRoot }, gateway: { enabled: true, error: gatewayError } })); +app.get("/api/users", (req, res) => req.user.role === "administrator" ? res.json(users.map(publicUser)) : res.status(403).json({ error: "Administrator access is required." })); +app.get("/api/audit", (req, res) => req.user.role === "administrator" ? res.json(storage.listAudit({ user: req.query.user, action: req.query.action, status: req.query.status }).map(item => ({ ...item, actor: users.find(user => user.id === item.actor_id)?.username || "System" }))) : res.status(403).json({ error: "Administrator access is required." })); +app.post("/api/users", async (req, res, next) => { + try { + const username = String(req.body.username || "").trim().toLowerCase(); + const displayName = String(req.body.displayName || "").trim(); + const password = String(req.body.password || ""); + const role = ["administrator", "standard", "viewer"].includes(req.body.role) ? req.body.role : "standard"; + if (!/^[a-z0-9][a-z0-9._-]{2,63}$/.test(username)) return res.status(400).json({ error: "Username must be 3–64 characters using letters, numbers, periods, hyphens, or underscores." }); + if (users.some(user => user.username === username)) return res.status(409).json({ error: "That username already exists." }); + if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." }); + if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." }); + const now = new Date().toISOString(); + const user = { id: crypto.randomUUID(), username, displayName, role, status: "active", password: await passwordRecord(password), source: "local", createdAt: now, updatedAt: now, lastLoginAt: null }; + users.push(user); await saveUsers(); recordActivity(`User “${user.username}” created as ${role === "administrator" ? "Administrator" : role === "viewer" ? "Viewer" : "Standard User"}.`); + res.status(201).json(publicUser(user)); + } catch (error) { next(error); } +}); +app.patch("/api/users/:id", async (req, res, next) => { + try { + const user = users.find(item => item.id === req.params.id); + if (!user) return res.status(404).json({ error: "User not found." }); + const nextRole = req.body.role === undefined ? user.role : ["administrator", "standard", "viewer"].includes(req.body.role) ? req.body.role : null; + if (!nextRole) return res.status(400).json({ error: "Invalid user role." }); + const nextStatus = req.body.status === undefined ? user.status : ["active", "disabled", "archived"].includes(req.body.status) ? req.body.status : null; + if (!nextStatus) return res.status(400).json({ error: "Invalid user status." }); + const removesActiveAdmin = user.role === "administrator" && user.status === "active" && (nextRole !== "administrator" || nextStatus !== "active"); + if (removesActiveAdmin && activeAdministrators().length === 1) return res.status(400).json({ error: "At least one active Administrator is required." }); + if (user.id === req.user.id && nextStatus !== "active") return res.status(400).json({ error: "You cannot disable or archive your own account." }); + if (user.id === req.user.id && nextRole !== user.role) return res.status(400).json({ error: "Another Administrator must change your role." }); + user.role = nextRole; user.status = nextStatus; + if (req.body.displayName !== undefined) { + const displayName = String(req.body.displayName).trim(); + if (!displayName || displayName.length > 80) return res.status(400).json({ error: "Display name is required and must be 80 characters or fewer." }); + user.displayName = displayName; + } + if (req.body.password !== undefined) { + const password = String(req.body.password); + if (password.length < 8) return res.status(400).json({ error: "Password must contain at least 8 characters." }); + user.password = await passwordRecord(password); + } + user.updatedAt = new Date().toISOString(); await saveUsers(); recordActivity(`User “${user.username}” updated · ${user.role === "administrator" ? "Administrator" : user.role === "viewer" ? "Viewer" : "Standard User"} · ${user.status}.`); + res.json(publicUser(user)); + } catch (error) { next(error); } +}); +app.delete("/api/users/:id", async (req, res, next) => { + try { + if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); + if (req.params.id === req.user.id) return res.status(400).json({ error: "You cannot delete your own account." }); + const index = users.findIndex(user => user.id === req.params.id); + if (index < 0) return res.status(404).json({ error: "User not found." }); + const [removed] = users.splice(index, 1); + groups.forEach(group => { group.members = (group.members || []).filter(id => id !== removed.id); }); + await Promise.all([saveUsers(), saveGroups()]); + recordActivity(`User “${removed.username}” permanently deleted.`); + res.status(204).end(); + } catch (error) { next(error); } +}); +app.get("/api/sites", (req, res) => res.json(sites.map(publicSite))); +app.get("/api/proxies", (req, res) => res.json(proxies.map(proxy => publicProxy(proxy, req.user.role === "administrator")))); +app.get("/api/redirects", (req, res) => res.json(redirects)); +app.get("/api/access-lists", (req, res) => res.json(accessLists.map(({ credentials, ...item }) => ({ ...item, credentials: (credentials || []).map(({ username }) => ({ username })), groups: item.groups || [] })))); +app.get("/api/groups", (req, res) => req.user.role === "administrator" ? res.json(groups.map(group => ({ ...group, memberIds: [...(group.members || [])], members: (group.members || []).map(id => users.find(user => user.id === id)?.username).filter(Boolean) }))) : res.status(403).json({ error: "Administrator access is required." })); +app.post("/api/access-lists/:id/groups", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const list = accessLists.find(value => value.id === req.params.id); if (!list) return res.status(404).json({ error: "Access List not found." }); list.groups = Array.isArray(req.body.groups) ? [...new Set(req.body.groups)].filter(id => groups.some(group => group.id === id && group.enabled !== false)) : []; await saveAccessLists(); recordActivity("Groups updated for Access List “" + list.name + "”."); res.json({ groups: list.groups }); } catch (error) { next(error); } }); +app.post("/api/groups", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); if (groups.some(group => group.name.toLowerCase() === name.toLowerCase())) return res.status(409).json({ error: "That group already exists." }); const group = { id: "group-" + crypto.randomBytes(4).toString("hex"), name, enabled: true, members: [], createdAt: new Date().toISOString() }; groups.push(group); await saveGroups(); recordActivity("Group “" + name + "” created."); res.status(201).json(group); } catch (error) { next(error); } }); +app.patch("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const group = groups.find(value => value.id === req.params.id); if (!group) return res.status(404).json({ error: "Group not found." }); if (req.body.name !== undefined) { const name = String(req.body.name || "").trim().slice(0, 80); if (!name) return res.status(400).json({ error: "Group name is required." }); group.name = name; } if (req.body.enabled !== undefined) group.enabled = Boolean(req.body.enabled); if (Array.isArray(req.body.members)) group.members = [...new Set(req.body.members)].filter(id => users.some(user => user.id === id)); await saveGroups(); recordActivity("Group “" + group.name + "” updated."); res.json(group); } catch (error) { next(error); } }); +app.delete("/api/groups/:id", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const index = groups.findIndex(value => value.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Group not found." }); const [group] = groups.splice(index, 1); await saveGroups(); recordActivity("Group “" + group.name + "” deleted."); res.status(204).end(); } catch (error) { next(error); } }); +app.get("/api/settings", (req, res) => req.user.role === "administrator" ? res.json({ ...settings, backupDirectory: backupsDir }) : res.status(403).json({ error: "Administrator access is required." })); +app.post("/api/settings/verify-admin", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(422).json({ error:"Administrator username or password is incorrect." }); res.json({ ok:true }); } catch (error) { next(error); } }); +app.post("/api/settings/verify-username", (req, res) => { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); const username = String(req.body.username || "").trim().toLowerCase(); res.json({ valid: Boolean(username && username === String(req.user.username || "").toLowerCase()) }); }); +app.get("/api/dashboard", async (req, res, next) => { + try { res.json(await dashboardSnapshot()); } + catch (error) { next(error); } +}); +app.get("/api/certificates", async (req, res, next) => { + try { res.json(await certificateInventory()); } + catch (error) { next(error); } +}); +app.post("/api/health/check", async (req, res, next) => { + try { await checkAllProxies(); res.json({ dashboard: await dashboardSnapshot(), certificates: await certificateInventory(), readiness: await domainReadiness() }); } + catch (error) { next(error); } +}); +app.get("/api/readiness", async (req, res, next) => { try { res.json({ checkedAt: new Date().toISOString(), routes: await domainReadiness() }); } catch (error) { next(error); } }); +app.get("/api/support-report", async (req, res, next) => { + try { + if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); + const certificateReport = await certificateInventory(); + certificateReport.latestError = certificateReport.latestError ? { present:true, at:certificateReport.latestError.at } : null; + const report = { product: "Site Gateway", generatedAt: new Date().toISOString(), version: appVersion, caddyVersion, nodeVersion: process.version, storage: { engine: "SQLite", integrity: storage.integrity() }, gateway: { healthy: !gatewayError, lastReload: lastGatewayReload }, routes: { hosted: sites.map(({ id,name,domain,tls,enabled,port }) => ({ id,name,domain,tls,enabled,port })), proxies: proxies.map(({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected }) => ({ id,name,domain,tls,enabled,target,healthEnabled,healthExpected })), redirects: redirects.map(({ id,name,domain,tls,enabled,code }) => ({ id,name,domain,tls,enabled,code })) }, certificates: certificateReport, readiness: await domainReadiness(), recentEvents: recentActivity.slice(0,20).map(item => ({ at:item.at, status:item.status, message:item.status === "error" ? "Operational error recorded; review the protected in-app event log for details." : item.message })) }; + res.setHeader("Content-Disposition", `attachment; filename="site-gateway-support-${new Date().toISOString().slice(0,10)}.json"`); res.type("json").send(JSON.stringify(report, null, 2)); + } catch (error) { next(error); } +}); +app.get("/api/upstreams", (req, res) => res.json(proxies.map(publicProxy))); +app.post("/api/upstreams/check", async (req, res, next) => { + try { res.json(await checkAllProxies()); } + catch (error) { next(error); } +}); +app.get("/api/logs", async (req, res, next) => { + try { + const limit = Math.min(Math.max(Number.parseInt(req.query.limit, 10) || 100, 1), 250); + const host = normalizeDomain(req.query.host); + res.json({ entries: storage.listAccessEvents(limit, host), hosts: [...new Set([...sites, ...proxies, ...redirects].flatMap(item => normalizeDomains(item.domain, item.domains)))].sort(), activity: recentActivity }); + } catch (error) { next(error); } +}); +app.get("/api/icons/search", async (req, res, next) => { + try { + const query = String(req.query.q || "").trim().toLowerCase().slice(0, 80); + if (query.length < 2) return res.json([]); + const catalog = await loadIconCatalog(); + const results = Object.entries(catalog).map(([slug, metadata]) => { + const aliases = metadata.aliases || []; + const searchText = [slug, ...aliases, ...(metadata.categories || [])].join(" ").toLowerCase(); + const score = slug === query ? 0 : slug.startsWith(query) ? 1 : aliases.some(alias => alias.toLowerCase() === query) ? 2 : searchText.includes(query) ? 3 : 99; + return { slug, metadata, aliases, score }; + }).filter(item => item.score < 99).sort((left, right) => left.score - right.score || left.slug.localeCompare(right.slug)).slice(0, 30) + .map(({ slug, aliases }) => ({ slug, label: iconLabel(slug), aliases: aliases.slice(0, 3), preview: `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg` })); + res.json(results); + } catch (error) { next(error); } +}); +app.put("/api/:kind/:id/icon", async (req, res, next) => { + try { + const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null; + if (!collection) return res.status(404).json({ error: "Entry type not found." }); + const item = collection.find(entry => entry.id === req.params.id); + if (!item) return res.status(404).json({ error: "Entry not found." }); + if (req.body.url !== undefined) { + const url = String(req.body.url || "").trim(); + if (!/^https:\/\//i.test(url) || url.length > 2048) return res.status(400).json({ error: "Icon URL must be a valid HTTPS URL under 2048 characters." }); + item.iconSlug = null; item.icon = url; + if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists(); + recordActivity(`Icon URL updated for “${item.name}”.`); + return res.json(item); + } + const slug = String(req.body.slug || "").trim(); + const icon = slug ? await cacheIcon(slug) : null; + item.iconSlug = slug || null; + item.icon = icon; + if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else await saveAccessLists(); + recordActivity(`${slug ? "Icon updated" : "Icon reset"} for “${item.name}”.`); + res.json(item); + } catch (error) { next(error); } +}); +app.post("/api/:kind/:id/icon", iconUpload.single("icon"), async (req, res, next) => { + try { + const collection = req.params.kind === "sites" ? sites : req.params.kind === "proxies" ? proxies : req.params.kind === "redirects" ? redirects : req.params.kind === "access-lists" ? accessLists : req.params.kind === "groups" ? groups : req.params.kind === "users" ? users : null; + if (!collection) return res.status(404).json({ error: "Entry type not found." }); + const item = collection.find(entry => entry.id === req.params.id); + if (!item) return res.status(404).json({ error: "Entry not found." }); + if (!req.file) return res.status(400).json({ error: "Choose an icon image." }); + if (!/^image\/(png|jpeg|webp|gif|svg\+xml)$/.test(req.file.mimetype)) return res.status(400).json({ error: "Use PNG, JPEG, WebP, GIF, or SVG." }); + const extension = req.file.mimetype === "image/svg+xml" ? "svg" : req.file.mimetype.split("/")[1].replace("jpeg", "jpg"); + const filename = `${req.params.kind}-${item.id}.${extension}`; + await fsp.rename(req.file.path, path.join(iconsDir, filename)); + item.iconSlug = null; item.icon = `/site-icons/${filename}`; + if (collection === sites) await saveSites(); else if (collection === proxies) await saveProxies(); else if (collection === redirects) await saveRedirects(); else if (collection === groups) await saveGroups(); else if (collection === users) await saveUsers(); else await saveAccessLists(); + recordActivity(`Custom icon uploaded for “${item.name}”.`); + res.json(item); + } catch (error) { next(error); } + finally { if (req.file?.path) await fsp.rm(req.file.path, { force: true }).catch(() => {}); } +}); +app.post("/api/sites", upload.single("files"), async (req, res, next) => { + try { + const name = String(req.body.name || "").trim(); + const port = Number.parseInt(req.body.port, 10); + const domain = normalizeDomain(req.body.domain); + const domains = normalizeDomains(domain, req.body.domains); + const tls = ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : "automatic"; + const hsts = req.body.hsts === "true"; + const id = `${slugify(name) || "site"}-${crypto.randomBytes(3).toString("hex")}`; + if (!name) throw Object.assign(new Error("Site name is required."), { status: 400 }); + const portError = validatePort(port); + if (portError) throw Object.assign(new Error(portError), { status: 400 }); + const domainError = validateDomains(domains); + if (domainError) throw Object.assign(new Error(domainError), { status: 400 }); + if (!req.file) throw Object.assign(new Error("Choose a ZIP file or index.html."), { status: 400 }); + const accessListId = String(req.body.accessListId || ""); + const site = { id, name, port, domain, domains, accessListId, tls, hsts, enabled: true, createdAt: new Date().toISOString() }; + applyAdvancedSettings(site, { accessListId, compression: req.body.compression, hstsSubdomains: req.body.hstsSubdomains === "true", customConfig: req.body.customConfig, healthEnabled: req.body.healthEnabled === true || (typeof req.body.healthEnabled === "string" && req.body.healthEnabled.toLowerCase() === "true"), healthPath: req.body.healthPath, healthMethod: req.body.healthMethod, healthExpected: req.body.healthExpected, healthTimeoutSeconds: req.body.healthTimeoutSeconds, healthRetries: req.body.healthRetries }); + await installUpload(site, req.file); + sites.push(site); + try { await startSite(site); } catch (error) { sites = sites.filter(item => item.id !== site.id); await fsp.rm(path.join(sitesDir, site.id), { recursive: true, force: true }); throw Object.assign(new Error(`Could not start the hosted site on port ${port}: ${error.message}`), { status: 409 }); } + await syncCaddy(); + await saveSites(); + recordActivity(`Hosted site “${site.name}” created.`); + res.status(201).json(publicSite(site)); + } catch (error) { + if (req.file) await fsp.rm(req.file.path, { force: true }); + next(error); + } +}); +app.post("/api/sites/:id/toggle", async (req, res, next) => { + try { + const site = sites.find(item => item.id === req.params.id); + if (!site) return res.status(404).json({ error: "Site not found." }); + site.enabled = !site.enabled; + await restartSite(site); + await syncCaddy(); + await saveSites(); + recordActivity(`Hosted site “${site.name}” ${site.enabled ? "enabled" : "disabled"}.`); + res.json(publicSite(site)); + } catch (error) { next(error); } +}); +app.post("/api/sites/:id/files", upload.single("files"), async (req, res, next) => { + try { + const site = sites.find(item => item.id === req.params.id); + if (!site) return res.status(404).json({ error: "Site not found." }); + if (!req.file) return res.status(400).json({ error: "Choose a ZIP file or index.html." }); + await installUpload(site, req.file); + await syncCaddy(); + recordActivity(`Files replaced for “${site.name}”.`); + res.json(publicSite(site)); + } catch (error) { next(error); } +}); +app.delete("/api/sites/:id", async (req, res, next) => { + try { + const index = sites.findIndex(item => item.id === req.params.id); + if (index < 0) return res.status(404).json({ error: "Site not found." }); + const [site] = sites.splice(index, 1); + await stopSite(site.id); + await syncCaddy(); + await fsp.rm(path.join(sitesDir, site.id), { recursive: true, force: true }); + await saveSites(); + recordActivity(`Hosted site “${site.name}” deleted.`); + res.status(204).end(); + } catch (error) { next(error); } +}); +app.patch("/api/sites/:id", async (req, res, next) => { + try { + const site = sites.find(item => item.id === req.params.id); + if (!site) return res.status(404).json({ error: "Site not found." }); + const domain = normalizeDomain(req.body.domain); + const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : site.domains); + const domainError = validateDomains(domains, site.id); + if (domainError) return res.status(400).json({ error: domainError }); + site.domain = domain; site.domains = domains; + site.tls = ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : "automatic"; + site.hsts = req.body.hsts === true; + applyAdvancedSettings(site, { accessListId: req.body.accessListId, compression: req.body.compression, hstsSubdomains: req.body.hstsSubdomains, requestHeaders: req.body.requestHeaders, responseHeaders: req.body.responseHeaders, customConfig: req.body.customConfig, healthEnabled: req.body.healthEnabled, healthPath: req.body.healthPath, healthMethod: req.body.healthMethod, healthExpected: req.body.healthExpected, healthTimeoutSeconds: req.body.healthTimeoutSeconds, healthRetries: req.body.healthRetries }); + if (site.healthEnabled === false) upstreamHealth.set(site.id, { status: "unmonitored", checkedAt: null, history: [] }); + else { upstreamHealth.set(site.id, { status: "pending", checkedAt: null, history: [] }); checkProxy({ ...site, target: `http://127.0.0.1:${site.port}` }).catch(error => console.warn("Hosted site health check failed:", error.message)); } + await syncCaddy(); + await saveSites(); + recordActivity(`Gateway settings updated for “${site.name}”.`); + res.json(publicSite(site)); + } catch (error) { next(error); } +}); +app.post("/api/proxies", async (req, res, next) => { + try { + const name = String(req.body.name || "").trim(); + const domain = normalizeDomain(req.body.domain); + const domains = normalizeDomains(domain, req.body.domains); + if (!name) return res.status(400).json({ error: "Proxy name is required." }); + const domainError = validateDomains(domains); + if (domainError || !domain) return res.status(400).json({ error: domainError || "Domain is required." }); + const proxy = { + id: `${slugify(name) || "proxy"}-${crypto.randomBytes(3).toString("hex")}`, + name, + domain, domains, + target: validateTarget(req.body.target), + tls: ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : "automatic", + hsts: req.body.hsts === true, + enabled: true, + createdAt: new Date().toISOString() + }; + applyAdvancedSettings(proxy, req.body); + if (proxy.healthEnabled === false) upstreamHealth.set(proxy.id, { status: "unmonitored", checkedAt: null, history: [] }); + else { upstreamHealth.set(proxy.id, { status: "pending", checkedAt: null, history: [] }); checkProxy(proxy).catch(error => console.warn("Proxy health check failed:", error.message)); } + proxies.push(proxy); + await syncCaddy(); + await saveProxies(); + recordActivity(`Proxy host “${proxy.name}” created.`); + res.status(201).json(publicProxy(proxy)); + } catch (error) { next(error); } +}); +app.patch("/api/proxies/:id", async (req, res, next) => { + try { + const proxy = proxies.find(item => item.id === req.params.id); + if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); + if (req.body.domain !== undefined) { + const domain = normalizeDomain(req.body.domain); + const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : proxy.domains); + const domainError = validateDomains(domains, proxy.id); + if (domainError || !domain) return res.status(400).json({ error: domainError || "Domain is required." }); + proxy.domain = domain; proxy.domains = domains; + } + if (req.body.domains !== undefined && req.body.domain === undefined) { const domains = normalizeDomains(proxy.domain, req.body.domains); const domainError = validateDomains(domains, proxy.id); if (domainError) return res.status(400).json({ error: domainError }); proxy.domains = domains; } + if (req.body.name !== undefined) { + const name = String(req.body.name).trim(); + if (!name) return res.status(400).json({ error: "Proxy name is required." }); + proxy.name = name; + } + if (req.body.target !== undefined) proxy.target = validateTarget(req.body.target); + if (req.body.tls !== undefined) proxy.tls = req.body.tls === "custom" && proxy.certificatePath && proxy.keyPath ? "custom" : ["http", "automatic", "internal"].includes(req.body.tls) ? req.body.tls : proxy.tls; + if (req.body.hsts !== undefined) proxy.hsts = req.body.hsts === true; + applyAdvancedSettings(proxy, req.body); + await syncCaddy(); + await saveProxies(); + recordActivity(`Proxy host “${proxy.name}” updated.`); + res.json(publicProxy(proxy)); + } catch (error) { next(error); } +}); +app.post("/api/proxies/:id/certificate", certificateUpload.fields([{ name: "certificate", maxCount: 1 }, { name: "privateKey", maxCount: 1 }]), async (req, res, next) => { + const files = Object.values(req.files || {}).flat(); + try { + const proxy = proxies.find(item => item.id === req.params.id); if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); + const certificateFile = req.files?.certificate?.[0], keyFile = req.files?.privateKey?.[0]; + if (!certificateFile || !keyFile) return res.status(400).json({ error: "Choose both the PEM certificate and private key." }); + const certificatePem = await fsp.readFile(certificateFile.path, "utf8"), keyPem = await fsp.readFile(keyFile.path, "utf8"); + const certificate = new crypto.X509Certificate(certificatePem), privateKey = crypto.createPrivateKey(keyPem), publicFromKey = crypto.createPublicKey(privateKey); + const certificatePublic = certificate.publicKey.export({ type: "spki", format: "der" }), suppliedPublic = publicFromKey.export({ type: "spki", format: "der" }); + if (!certificatePublic.equals(suppliedPublic)) return res.status(400).json({ error: "The private key does not match the certificate." }); + const certificateDomains = normalizeDomains(proxy.domain, proxy.domains); if (!certificateDomains.every(domain => certificate.checkHost(domain))) return res.status(400).json({ error: "The certificate must cover the primary domain and every additional domain." }); + const destination = path.join(customCertificatesDir, proxy.id); await fsp.mkdir(destination, { recursive: true }); + const certificatePath = path.join(destination, "certificate.pem"), keyPath = path.join(destination, "private-key.pem"); + await fsp.writeFile(certificatePath, certificatePem, { mode: 0o600 }); await fsp.writeFile(keyPath, keyPem, { mode: 0o600 }); + proxy.tls = "custom"; proxy.certificatePath = certificatePath; proxy.keyPath = keyPath; await syncCaddy(); await saveProxies(); recordActivity(`Custom certificate installed for “${proxy.name}”.`); res.json(publicProxy(proxy)); + } catch (error) { next(Object.assign(new Error(error.message || "Could not read that certificate."), { status: error.status || 400 })); } + finally { await Promise.all(files.map(file => fsp.rm(file.path, { force: true }))); } +}); +app.post("/api/proxies/:id/toggle", async (req, res, next) => { + try { + const proxy = proxies.find(item => item.id === req.params.id); + if (!proxy) return res.status(404).json({ error: "Proxy host not found." }); + proxy.enabled = !proxy.enabled; + await syncCaddy(); + await saveProxies(); + recordActivity(`Proxy host “${proxy.name}” ${proxy.enabled ? "enabled" : "disabled"}.`); + res.json(publicProxy(proxy)); + } catch (error) { next(error); } +}); +app.delete("/api/proxies/:id", async (req, res, next) => { + try { + const index = proxies.findIndex(item => item.id === req.params.id); + if (index < 0) return res.status(404).json({ error: "Proxy host not found." }); + const [proxy] = proxies.splice(index, 1); + await syncCaddy(); + await saveProxies(); + recordActivity(`Proxy host “${proxy.name}” deleted.`); + res.status(204).end(); + } catch (error) { next(error); } +}); + +app.post("/api/access-lists", async (req, res, next) => { + try { + const name = String(req.body.name || "").trim(); + if (!name || name.length > 80) return res.status(400).json({ error: "Access List name is required and must be 80 characters or fewer." }); + const networks = String(req.body.networks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); + const deniedNetworks = String(req.body.deniedNetworks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); + if (networks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter IP addresses, CIDR ranges, or private_ranges, one per line." }); + if (deniedNetworks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter valid denied IP addresses or CIDR ranges." }); + const credentials = []; + for (const entry of Array.isArray(req.body.credentials) ? req.body.credentials.slice(0, 25) : []) { + const username = String(entry.username || "").trim(); const password = String(entry.password || ""); + if (!/^[A-Za-z0-9._-]{1,64}$/.test(username) || password.length < 8) return res.status(400).json({ error: "Access usernames must be valid and passwords must contain at least 8 characters." }); + const { stdout } = await execFileAsync("caddy", ["hash-password", "--plaintext", password]); + credentials.push({ username, hash: stdout.trim(), password: await passwordRecord(password) }); + } + if (!networks.length && !deniedNetworks.length && !credentials.length) return res.status(400).json({ error: "Add at least one network rule or login." }); + const selectedGroups = Array.isArray(req.body.groups) ? [...new Set(req.body.groups)].filter(id => groups.some(group => group.id === id && group.enabled !== false)) : []; + const item = { id: `access-${crypto.randomBytes(4).toString("hex")}`, name, networks, deniedNetworks, credentials, groups: selectedGroups, enabled: true, createdAt: new Date().toISOString() }; + accessLists.push(item); await syncCaddy(); await saveAccessLists(); recordActivity(`Access List “${name}” created.`); + res.status(201).json({ ...item, credentials: credentials.map(({ username }) => ({ username })) }); + } catch (error) { next(error); } +}); +app.patch("/api/access-lists/:id", async (req, res, next) => { + try { + const item = accessLists.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Access List not found." }); + if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled); + if (req.body.name) item.name = String(req.body.name).trim().slice(0, 80); + if (req.body.networks !== undefined) { + const networks = String(req.body.networks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); + if (networks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter IP addresses, CIDR ranges, or private_ranges, one per line." }); + item.networks = networks; + } + if (req.body.deniedNetworks !== undefined) { + const deniedNetworks = String(req.body.deniedNetworks || "").split(/[\n,]+/).map(value => value.trim()).filter(Boolean); + if (deniedNetworks.some(value => !/^(?:private_ranges|(?:\d{1,3}\.){3}\d{1,3}(?:\/\d{1,2})?|[0-9a-f:]+(?:\/\d{1,3})?)$/i.test(value))) return res.status(400).json({ error: "Enter valid denied IP addresses or CIDR ranges." }); + item.deniedNetworks = deniedNetworks; + } + if (Array.isArray(req.body.credentials)) { + const credentials = []; + for (const entry of req.body.credentials.slice(0, 25)) { const username = String(entry.username || "").trim(), password = String(entry.password || ""); if (!/^[A-Za-z0-9._-]{1,64}$/.test(username)) return res.status(400).json({ error: "Access usernames must use letters, numbers, dots, underscores, or hyphens." }); if (!password) { const existing = item.credentials?.find(value => value.username === username); if (!existing) return res.status(400).json({ error: `Enter a password for new user ${username}.` }); credentials.push(existing); continue; } if (password.length < 8) return res.status(400).json({ error: "Passwords must contain at least 8 characters." }); const { stdout } = await execFileAsync("caddy", ["hash-password", "--plaintext", password]); credentials.push({ username, hash: stdout.trim(), password: await passwordRecord(password) }); } + item.credentials = credentials; + } + if (!(item.networks || []).length && !(item.deniedNetworks || []).length && !(item.credentials || []).length) return res.status(400).json({ error: "Keep at least one network rule or login." }); + await syncCaddy(); await saveAccessLists(); recordActivity(`Access List “${item.name}” updated.`); res.json({ ...item, credentials: (item.credentials || []).map(({ username }) => ({ username })) }); + } catch (error) { next(error); } +}); +app.post("/api/access-lists/:id/assignments", async (req, res, next) => { + try { + const list = accessLists.find(value => value.id === req.params.id); if (!list) return res.status(404).json({ error: "Access List not found." }); + const collections = { sites, proxies, redirects }; const kind = String(req.body.kind || ""); const collection = collections[kind]; const host = collection?.find(value => value.id === req.body.hostId); + if (!host) return res.status(404).json({ error: "Host not found." }); + host.accessListId = req.body.assigned === false ? "" : list.id; + await syncCaddy(); if (kind === "sites") await saveSites(); else if (kind === "proxies") await saveProxies(); else await saveRedirects(); + recordActivity("Access List " + list.name + (host.accessListId ? " assigned to " : " removed from ") + (host.name || host.domain) + "."); + res.json({ ok: true, accessListId: host.accessListId }); + } catch (error) { next(error); } +}); +app.delete("/api/access-lists/:id", async (req, res, next) => { + try { + if ([...sites, ...proxies, ...redirects].some(item => item.accessListId === req.params.id)) return res.status(409).json({ error: "Remove this Access List from all hosts before deleting it." }); + const index = accessLists.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Access List not found." }); + const [item] = accessLists.splice(index, 1); await saveAccessLists(); recordActivity(`Access List “${item.name}” deleted.`); res.status(204).end(); + } catch (error) { next(error); } +}); + +app.post("/api/redirects", async (req, res, next) => { + try { + const name = String(req.body.name || "").trim(); const domain = normalizeDomain(req.body.domain); const domains = normalizeDomains(domain, req.body.domains); const target = String(req.body.target || "").trim().replace(/\/$/, ""); + const domainError = validateDomains(domains); if (!name || domainError || !domain) return res.status(400).json({ error: domainError || "Name and primary source domain are required." }); + try { const parsed = new URL(target); if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error(); } catch { return res.status(400).json({ error: "Destination must be a complete HTTP or HTTPS URL." }); } + const item = { id: `redirect-${crypto.randomBytes(4).toString("hex")}`, name, domain, domains, target, code: [301,302,307,308].includes(Number(req.body.code)) ? Number(req.body.code) : 302, preservePath: req.body.preservePath !== false, tls: ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : "automatic", hsts: Boolean(req.body.hsts), accessListId: String(req.body.accessListId || ""), enabled: true, createdAt: new Date().toISOString() }; + redirects.push(item); await syncCaddy(); await saveRedirects(); recordActivity(`Redirect Host “${name}” created.`); res.status(201).json(item); + } catch (error) { next(error); } +}); +app.patch("/api/redirects/:id", async (req, res, next) => { + try { + const item = redirects.find(value => value.id === req.params.id); if (!item) return res.status(404).json({ error: "Redirect Host not found." }); + if (req.body.domain !== undefined || req.body.domains !== undefined) { const domain = normalizeDomain(req.body.domain ?? item.domain); const domains = normalizeDomains(domain, req.body.domains !== undefined ? req.body.domains : item.domains); const error = validateDomains(domains, item.id); if (error || !domain) return res.status(400).json({ error: error || "Primary source domain is required." }); item.domain = domain; item.domains = domains; } + if (req.body.enabled !== undefined) item.enabled = Boolean(req.body.enabled); + for (const key of ["name","target","accessListId"]) if (req.body[key] !== undefined) item[key] = String(req.body[key]).trim(); + if (req.body.target !== undefined) { try { const parsed = new URL(item.target); if (!['http:','https:'].includes(parsed.protocol)) throw new Error(); } catch { return res.status(400).json({ error: "Destination must be a complete HTTP or HTTPS URL." }); } } + if (req.body.code !== undefined && [301,302,307,308].includes(Number(req.body.code))) item.code = Number(req.body.code); + if (req.body.preservePath !== undefined) item.preservePath = Boolean(req.body.preservePath); + if (req.body.tls !== undefined) item.tls = ["http","automatic","internal"].includes(req.body.tls) ? req.body.tls : item.tls; + if (req.body.hsts !== undefined) item.hsts = Boolean(req.body.hsts); + await syncCaddy(); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” updated.`); res.json(item); + } catch (error) { next(error); } +}); +app.delete("/api/redirects/:id", async (req, res, next) => { + try { const index = redirects.findIndex(item => item.id === req.params.id); if (index < 0) return res.status(404).json({ error: "Redirect Host not found." }); const [item] = redirects.splice(index, 1); await syncCaddy(); await saveRedirects(); recordActivity(`Redirect Host “${item.name}” deleted.`); res.status(204).end(); } catch (error) { next(error); } +}); + +app.patch("/api/settings", async (req, res, next) => { + try { + if (req.body.defaultSite) { + const value = req.body.defaultSite; const mode = ["welcome","themed404","abort","redirect","custom"].includes(value.mode) ? value.mode : "themed404"; + settings.defaultSite = { mode, redirectUrl: String(value.redirectUrl || "").trim(), redirectCode: [301,302,307,308].includes(Number(value.redirectCode)) ? Number(value.redirectCode) : 302, preservePath: value.preservePath !== false, title: String(value.title || "").slice(0, 100), message: String(value.message || "").slice(0, 500), customHtml: String(value.customHtml || "").slice(0, 250000) }; + } + if (req.body.backups) settings.backups = { ...settings.backups, ...req.body.backups, hour: Math.min(Math.max(Number(req.body.backups.hour) || 0, 0), 23), retention: Math.min(Math.max(Number(req.body.backups.retention) || 7, 1), 100) }; + if (req.body.certificateHealth) { + const warningDays = Math.min(Math.max(Number(req.body.certificateHealth.warningDays) || 30, 8), 120); + const criticalDays = Math.min(Math.max(Number(req.body.certificateHealth.criticalDays) || 7, 1), warningDays - 1); + settings.certificateHealth = { warningDays, criticalDays, staleMinutes: Math.min(Math.max(Number(req.body.certificateHealth.staleMinutes) || 10, 2), 1440) }; + } + if (req.body.logsRetention) { + const value = req.body.logsRetention; + const days = key => Math.min(Math.max(Number(value[key]) || 30, 7), 3650); + settings.logsRetention = { ...settings.logsRetention, accessDays: days("accessDays"), activityDays: days("activityDays"), auditDays: days("auditDays"), certificateDays: days("certificateDays"), securityDays: days("securityDays"), pruningEnabled: value.pruningEnabled === true }; + } + await syncCaddy(); await saveSettings(); recordActivity("Administration settings updated."); res.json({ ...settings, backupDirectory: backupsDir }); + } catch (error) { next(error); } +}); +app.post("/api/logs/prune", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); if (!settings.logsRetention?.pruningEnabled) return res.status(409).json({ error: "Automatic pruning is disabled. Enable it and save the retention policy first." }); const mode = req.body?.mode === "scheduled" ? "scheduled" : "manual"; const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: mode, lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`${mode === "scheduled" ? "Scheduled" : "Manual"} log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); res.json({ counts, snapshot }); } catch (error) { next(error); } }); +app.get("/api/logs/prune/preview", (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); res.json({ enabled: settings.logsRetention?.pruningEnabled === true, counts: storage.previewPruneEvents(settings.logsRetention || {}) }); } catch (error) { next(error); } }); +app.get("/api/logs/download", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error: "Administrator access is required." }); const payload = { product: "Site Gateway", generatedAt: new Date().toISOString(), access: storage.listAccessEvents(500), activity: storage.listActivity(500), audit: storage.listAudit({}) }; res.setHeader("Content-Disposition", `attachment; filename="site-gateway-logs-${new Date().toISOString().slice(0, 10)}.json"`); res.json(payload); } catch (error) { next(error); } }); +app.post("/api/settings/reset-defaults", async (req, res, next) => { try { if (req.user.role !== "administrator") return res.status(403).json({ error:"Administrator access is required." }); if (String(req.body.confirmation || "") !== "RESTORE DEFAULT") return res.status(400).json({ error:"Type RESTORE DEFAULT exactly to continue." }); if (String(req.body.username || "").trim().toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); settings.defaultSite = { mode:"themed404", redirectUrl:"", redirectCode:302, preservePath:true, title:"Route not found", message:"The gateway is responding, but this address has not been configured.", customHtml:"" }; settings.backups = { enabled:false, frequency:"daily", hour:2, retention:7, type:"configuration", includeLogs:false, encrypt:false, lastRunAt:null, lastStatus:null }; settings.certificateHealth = { warningDays:30, criticalDays:7, staleMinutes:10 }; await saveSettings(); recordActivity("Gateway preferences restored to defaults."); res.json({ ...settings, backupDirectory:backupsDir }); } catch (error) { next(error); } }); +app.post("/api/factory-reset", async (req, res, next) => { try { if (String(req.body.confirmation || "") !== "FACTORY RESET") return res.status(400).json({ error:"Type FACTORY RESET exactly to continue." }); if (String(req.body.username || "").toLowerCase() !== String(req.user.username || "").toLowerCase() || !await passwordMatches(String(req.body.password || ""), req.user.password)) return res.status(401).json({ error:"Administrator credentials were not accepted." }); await Promise.all([...activeServers.keys()].map(stopSite)); storage.close(); for (const directory of [sitesDir, uploadDir, caddyDir, iconsDir, logsDir, backupsDir, defaultSiteDir, certificatesRoot, path.join(dataDir,"database")]) await clearDirectoryContents(directory); storage = await openStorage(dataDir, backupsDir); sites = []; proxies = []; users = []; redirects = []; accessLists = []; groups = []; settings = {}; recentActivity.splice(0); await loadSites(); await syncCaddy(); res.setHeader("Set-Cookie", "webserver_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"); res.status(202).json({ ok:true }); } catch (error) { next(error); } }); +app.use("/api/backups", (req, res, next) => req.user.role === "administrator" ? next() : res.status(403).json({ error: "Administrator access is required." })); +app.get("/api/backups", async (req, res, next) => { try { res.json(await listBackups()); } catch (error) { next(error); } }); +app.post("/api/backups", async (req, res, next) => { + try { const backup = await createBackup(req.body.type, Boolean(req.body.includeLogs), "site-gateway-backup", String(req.body.password || "")); res.status(201).json(backup); } catch (error) { next(error); } +}); +app.post("/api/backups/import", upload.single("backup"), async (req, res, next) => { + try { + if (!req.file) return res.status(400).json({ error: "Choose a .sgbackup file." }); + const { zip } = await openBackup(req.file.path, String(req.body.password || "")); const manifest = JSON.parse(zip.readAsText("manifest.json") || "null"); + if (!manifest || manifest.product !== "Site Gateway" || ![1,2].includes(manifest.format)) throw Object.assign(new Error("This is not a supported Site Gateway backup."), { status: 400 }); + const filename = `imported-${new Date().toISOString().replace(/[:.]/g, "-")}.sgbackup`; await fsp.rename(req.file.path, path.join(backupsDir, filename)); + recordActivity(`Backup imported from this computer.`); res.status(201).json({ filename, manifest }); + } catch (error) { if (req.file) await fsp.rm(req.file.path, { force: true }); next(error); } +}); +app.get("/api/backups/:filename/download", async (req, res, next) => { + try { const filename = path.basename(req.params.filename); const file = path.join(backupsDir, filename); await fsp.access(file); res.download(file, filename); } catch (error) { next(Object.assign(new Error("Backup not found."), { status: 404 })); } +}); +app.post("/api/backups/:filename/restore", async (req, res, next) => { + try { res.json({ ok: true, manifest: await restoreBackup(path.basename(req.params.filename), String(req.body.password || "")) }); } catch (error) { next(error); } +}); +app.delete("/api/backups/:filename", async (req, res, next) => { + try { const filename = path.basename(req.params.filename); if (!filename.endsWith(".sgbackup")) return res.status(400).json({ error: "Invalid backup." }); await fsp.rm(path.join(backupsDir, filename)); recordActivity(`Backup ${filename} deleted.`); res.status(204).end(); } catch (error) { next(error); } +}); +function humanizeGatewayActivityError(message) { const text = String(message || "Unexpected gateway error"); if (/upstream address scheme is HTTP but transport is configured for HTTP\+TLS/i.test(text)) return "Gateway configuration rejected: HTTP upstream cannot use HTTPS transport. Disable upstream TLS verification or change the upstream URL to HTTPS."; if (/upstream address scheme is HTTPS but transport is configured for plain HTTP/i.test(text)) return "Gateway configuration rejected: HTTPS upstream requires HTTPS transport settings. Change the upstream URL or transport setting."; if (/duplicate.*address|already.*site address/i.test(text)) return "Gateway configuration rejected: This hostname or address is already used by another host. Choose a unique hostname and port."; if (/dial tcp|no such host|lookup .* no such host|upstream.*(invalid|malformed)/i.test(text)) return "Gateway configuration rejected: The upstream address could not be reached or is invalid. Check the hostname, IP address, and port."; if (/invalid hostname|host name.*invalid|malformed.*host/i.test(text)) return "Gateway configuration rejected: The hostname is not valid. Use a valid domain name without a protocol or path."; if (/unrecognized directive|unknown directive|parsing caddyfile tokens/i.test(text)) return "Gateway configuration rejected: The gateway configuration contains an unsupported or malformed directive. Check the selected host settings."; if (/certificate|tls.*(config|handshake)|no certificate/i.test(text)) return "Gateway configuration rejected: The TLS certificate configuration is invalid or unavailable. Check the certificate, key, and HTTPS settings."; return text.replace(/^Gateway configuration was rejected:\s*/i, "Gateway configuration rejected: ").replace(/\s+Details:\s+[\s\S]*$/i, ""); } +app.use((error, req, res, next) => { + console.error(error); + recordActivity(`${req.method} ${req.path}: ${humanizeGatewayActivityError(error.message)}`, "error"); + res.status(error.status || 500).json({ error: error.message || "Something went wrong." }); +}); + +app.listen(adminPort, "0.0.0.0", () => { + console.log(`Site Gateway dashboard listening on port ${adminPort}`); + if (adminPassword === "change-this-password") console.warn("WARNING: Change ADMIN_PASSWORD before exposing the dashboard."); +}); + +setTimeout(() => checkAllProxies().catch(error => console.warn("Initial upstream checks failed:", error.message)), 1500).unref(); +setInterval(() => checkAllProxies().catch(error => console.warn("Upstream checks failed:", error.message)), 60000).unref(); + +async function runScheduledBackup() { + const schedule = settings.backups || {}; if (!schedule.enabled || Number(schedule.hour) !== new Date().getHours()) return; + const last = schedule.lastRunAt ? new Date(schedule.lastRunAt) : null; const elapsed = last ? Date.now() - last.getTime() : Infinity; + const due = schedule.frequency === "monthly" ? elapsed >= 27 * 86400000 : schedule.frequency === "weekly" ? elapsed >= 6 * 86400000 : elapsed >= 20 * 3600000; + if (!due) return; + try { + if (schedule.encrypt && !scheduledBackupPassword) throw new Error("BACKUP_PASSWORD is required for encrypted scheduled backups."); + await createBackup(schedule.type, Boolean(schedule.includeLogs), "scheduled", schedule.encrypt ? scheduledBackupPassword : ""); + schedule.lastRunAt = new Date().toISOString(); schedule.lastStatus = "ok"; + const backups = (await listBackups()).filter(item => item.filename.startsWith("scheduled-")); + for (const item of backups.slice(Math.max(Number(schedule.retention) || 7, 1))) await fsp.rm(path.join(backupsDir, item.filename), { force: true }); + } catch (error) { schedule.lastRunAt = new Date().toISOString(); schedule.lastStatus = `error: ${error.message}`; recordActivity(`Scheduled backup failed: ${error.message}`, "error"); } + await saveSettings(); +} +setTimeout(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 5000).unref(); +setInterval(() => runScheduledBackup().catch(error => console.warn("Scheduled backup check failed:", error.message)), 15 * 60000).unref(); +async function runScheduledPruning() { if (!settings.logsRetention?.pruningEnabled || !storage?.pruneEvents) return; try { const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const snapshot = path.join(backupsDir, `pre-prune-${stamp}.sqlite`); storage.backupTo(snapshot); const counts = storage.pruneEvents(settings.logsRetention); settings.logsRetention = { ...settings.logsRetention, lastRunAt: new Date().toISOString(), lastRunMode: "scheduled", lastRunCounts: counts, lastRunSnapshot: snapshot }; await saveSettings(); recordActivity(`Scheduled log pruning completed: ${Object.values(counts).reduce((sum, value) => sum + value, 0)} records removed.`); } catch (error) { recordActivity(`Scheduled log pruning failed: ${error.message}`, "error"); } } +setInterval(() => runScheduledPruning(), 15 * 60000).unref(); +setTimeout(() => importAccessLogsToSqlite(), 8000).unref(); +setInterval(() => importAccessLogsToSqlite(), 30000).unref(); + +async function shutdown() { + await Promise.all([...activeServers.keys()].map(stopSite)); + try { storage?.close(); } catch { /* Database may already be closed during restore. */ } + process.exit(0); +} +process.on("SIGTERM", shutdown); +process.on("SIGINT", shutdown); diff --git a/src/storage.js b/src/storage.js new file mode 100644 index 0000000..374e9b5 --- /dev/null +++ b/src/storage.js @@ -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() }; +}