Add admin UI: per-host visibility overrides and manual targets

NPM stays the source of truth for which domains exist; this adds an
optional enrichment layer on top of the existing NPM_INCLUDE_PATTERNS
filtering, gated behind ADMIN_PASSWORD (disabled entirely if unset).

- Force-show/force-hide any NPM-discovered host regardless of what
  the include pattern would otherwise decide
- Per-host display name and check path overrides
- Manually add targets that aren't proxied through NPM at all
- Every override falls back to the NPM-derived default when unset —
  a host with no override behaves exactly as before this existed

Host overrides and manual targets live in SQLite (better-sqlite3,
WAL mode) and are re-read on every 60s check tick, so admin changes
take effect within one cycle with no restart. NPM host discovery
keeps its original 10-minute cadence — only the local override reads
happen every tick.

The core merge (NPM hosts + overrides + manual targets -> final
check list) is a pure function in targets.ts, kept separate from the
checker's I/O for testability. Verified end-to-end against a local
mock NPM server (auth, force-show/hide, rename, custom check path,
manual targets, reset-to-default all propagate correctly) and against
the actual Docker build/runtime (better-sqlite3's native addon builds
and loads correctly in the Alpine image).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-10 15:40:57 +02:00
parent e9d132cd02
commit b83d7aec01
15 changed files with 1187 additions and 24 deletions

View File

@@ -5,3 +5,7 @@ NPM_EMAIL=api@example.com
NPM_SECRET= NPM_SECRET=
# Comma-separated regex patterns for which domains to include # Comma-separated regex patterns for which domains to include
NPM_INCLUDE_PATTERNS=\.example\.com$,\.dev\.example\.com$ NPM_INCLUDE_PATTERNS=\.example\.com$,\.dev\.example\.com$
# Gates the /admin UI (host visibility overrides, manual targets). Leave
# unset to disable /admin entirely.
ADMIN_PASSWORD=change-me

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.env .env
data/

View File

@@ -1,5 +1,8 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
# better-sqlite3 has a native addon with no prebuilt binary for this
# platform/Node combo yet — build it from source.
RUN apk add --no-cache python3 make g++
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
@@ -7,8 +10,16 @@ RUN npm run build
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# Same native-addon rebuild needed for the production-only install below;
# the toolchain is purged again afterward to keep the runtime image lean.
RUN apk add --no-cache python3 make g++
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN npm install --omit=dev && apk del python3 make g++
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
# DATA_DIR (SQLite DB) is a mutable runtime volume — not baked into the
# image, so redeploys don't clobber host overrides / manual targets.
ENV DATA_DIR=/app/data
EXPOSE 3035 EXPOSE 3035
CMD ["node", "dist/index.js"] CMD ["node", "dist/index.js"]

View File

@@ -7,12 +7,27 @@ Designed to run as a standalone public status page on its own subdomain — if y
## How it works ## How it works
1. On startup (and every 10 minutes), fetches all enabled proxy hosts from the NPM API 1. On startup (and every 10 minutes), fetches all enabled proxy hosts from the NPM API
2. Filters to domains matching `NPM_INCLUDE_PATTERNS` 2. Filters to domains matching `NPM_INCLUDE_PATTERNS` — unless overridden per-host in the admin UI (see below)
3. HTTP-checks each host every 60 seconds 3. HTTP-checks each host every 60 seconds
4. Serves current results at `/` (HTML) and `/api/status` (JSON) 4. Serves current results at `/` (HTML) and `/api/status` (JSON)
The HTML page auto-refreshes every 60 seconds. The HTML page auto-refreshes every 60 seconds.
## Admin UI
NPM stays the source of truth for *which domains exist*. The optional admin UI at `/admin` lets you override how they're presented, without touching NPM itself:
- **Force-show or force-hide** any NPM-discovered host, regardless of what `NPM_INCLUDE_PATTERNS` would otherwise decide
- **Rename** a host's display name (e.g. "Wisp" instead of `wisp.dev.xplwd.com`)
- **Custom check path** per host, for anything whose root path isn't a meaningful health check
- **Manually add non-NPM targets** — anything not proxied through NPM (a raw-port service, or a third-party dependency you want on the same page), checked exactly like a discovered host
Every override is enrichment, not replacement: leave a field blank and it falls back to the NPM-derived default (domain name, root path, pattern-match result). A host removed from NPM entirely just stops appearing — its override row, if any, becomes inert rather than erroring.
Overrides are stored in SQLite (`DATA_DIR/statuspage.db`) and re-read on every 60-second check tick, so changes in the admin UI take effect within one cycle — no restart needed. The 10-minute NPM host-discovery refresh is unaffected; only the local override/manual-target reads happen every tick.
Set `ADMIN_PASSWORD` to enable `/admin` and its API (`/api/admin/*`, gated by an `X-Admin-Password` header, same stateless pattern as `wisp`'s upload gate). Leave it unset and `/admin` is disabled outright — the main status page and `/api/status` work exactly as before, unaffected.
## Running locally ## Running locally
```bash ```bash
@@ -30,7 +45,9 @@ npm run dev
| `NPM_BASE_URL` | `http://localhost:81` | Base URL of your Nginx Proxy Manager instance | | `NPM_BASE_URL` | `http://localhost:81` | Base URL of your Nginx Proxy Manager instance |
| `NPM_EMAIL` | — | NPM account email | | `NPM_EMAIL` | — | NPM account email |
| `NPM_SECRET` | — | NPM account password | | `NPM_SECRET` | — | NPM account password |
| `NPM_INCLUDE_PATTERNS` | `\.example\.com$` | Comma-separated regex patterns; only domains matching at least one are checked | | `NPM_INCLUDE_PATTERNS` | `\.example\.com$` | Comma-separated regex patterns; only domains matching at least one are checked by default |
| `ADMIN_PASSWORD` | — | Gates `/admin`. Unset disables the admin UI entirely |
| `DATA_DIR` | `./data` | Where the SQLite DB (host overrides, manual targets) lives |
## Docker ## Docker
@@ -72,3 +89,20 @@ GET /api/status
``` ```
`status` is `"up"` for any response (including 4xx — the server is reachable), `"down"` for connection errors and 5xx, `"unknown"` before the first check completes. `status` is `"up"` for any response (including 4xx — the server is reachable), `"down"` for connection errors and 5xx, `"unknown"` before the first check completes.
### Admin API
All routes below `/api/admin` (except `/auth/check`) require an `X-Admin-Password` header matching `ADMIN_PASSWORD`. Returns `503` for every route if `ADMIN_PASSWORD` is unset.
```
POST /api/admin/auth/check -- { ok: boolean }
GET /api/admin/hosts -- every enabled NPM host, annotated with matchesPattern + any override
PUT /api/admin/hosts/:domain -- { visibility?, displayName?, checkPath?, sortOrder? }
DELETE /api/admin/hosts/:domain -- reset to default (delete the override row)
GET /api/admin/manual-targets
POST /api/admin/manual-targets -- { name, url, checkPath? }
PUT /api/admin/manual-targets/:id -- { name?, url?, checkPath?, enabled?, sortOrder? }
DELETE /api/admin/manual-targets/:id
```
`visibility` is one of `"default"` (follow `NPM_INCLUDE_PATTERNS`), `"hidden"`, or `"shown"`.

View File

@@ -9,3 +9,12 @@ services:
- NPM_EMAIL=${NPM_EMAIL} - NPM_EMAIL=${NPM_EMAIL}
- NPM_SECRET=${NPM_SECRET} - NPM_SECRET=${NPM_SECRET}
- NPM_INCLUDE_PATTERNS=${NPM_INCLUDE_PATTERNS} - NPM_INCLUDE_PATTERNS=${NPM_INCLUDE_PATTERNS}
# Admin UI (/admin) is disabled entirely if this is unset.
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
volumes:
# Named volume, not a bind mount into the build context — the SQLite
# DB holding host overrides / manual targets must survive redeploys.
- statuspage-data:/app/data
volumes:
statuspage-data:

436
package-lock.json generated
View File

@@ -7,10 +7,13 @@
"": { "": {
"name": "status", "name": "status",
"version": "0.1.0", "version": "0.1.0",
"license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2" "express": "^4.19.2"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"tsx": "^4.15.0", "tsx": "^4.15.0",
@@ -459,6 +462,16 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -596,6 +609,57 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.5", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -620,6 +684,30 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -658,6 +746,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -703,6 +797,30 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -722,6 +840,15 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -751,6 +878,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -838,6 +974,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/express": { "node_modules/express": {
"version": "4.22.2", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -884,6 +1029,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -920,6 +1071,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -981,6 +1138,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1049,12 +1212,38 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1133,12 +1322,45 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1148,6 +1370,18 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1172,6 +1406,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1187,6 +1430,33 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1200,6 +1470,16 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -1240,6 +1520,35 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1266,6 +1575,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/send": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1389,6 +1710,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1398,6 +1764,52 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/toidentifier": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1426,6 +1838,18 @@
"fsevents": "~2.3.3" "fsevents": "~2.3.3"
} }
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1469,6 +1893,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1486,6 +1916,12 @@
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
} }
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
} }
} }
} }

View File

@@ -9,11 +9,13 @@
"start": "node dist/index.js" "start": "node dist/index.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.19.2" "express": "^4.19.2",
"better-sqlite3": "^11.3.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/better-sqlite3": "^7.6.11",
"tsx": "^4.15.0", "tsx": "^4.15.0",
"typescript": "^5.5.0" "typescript": "^5.5.0"
} }

106
src/admin.ts Normal file
View File

@@ -0,0 +1,106 @@
import { Router } from 'express';
import { config } from './config.js';
import { fetchAllProxyHosts, matchesIncludePatterns } from './npm.js';
import {
getAllOverrides,
upsertOverride,
deleteOverride,
listManualTargets,
createManualTarget,
updateManualTarget,
deleteManualTarget,
type Visibility,
} from './db.js';
export const router = Router();
function checkAdminPassword(req: import('express').Request): boolean {
return !!config.adminPassword && req.get('X-Admin-Password') === config.adminPassword;
}
// Every route below requires the header — stateless, same shape as wisp's
// upload gate, no session/cookie to manage.
router.use((req, res, next) => {
if (!config.adminPassword) {
res.status(503).json({ error: 'admin disabled — ADMIN_PASSWORD not set' });
return;
}
if (req.path === '/auth/check') { next(); return; }
if (!checkAdminPassword(req)) {
res.status(401).json({ error: 'bad password' });
return;
}
next();
});
router.post('/auth/check', (req, res) => {
res.json({ ok: config.adminPassword != null && checkAdminPassword(req) });
});
// Full NPM-discovered host list, each annotated with whether the current
// include-pattern would show it and any existing override — the admin UI
// needs the *unfiltered* view to let you force-show a pattern-excluded host.
router.get('/hosts', async (_req, res) => {
try {
const hosts = await fetchAllProxyHosts();
const overrides = getAllOverrides();
const data = hosts.map(h => {
const domain = h.domain_names[h.domain_names.length - 1];
const override = overrides.get(domain);
return {
domain,
matchesPattern: matchesIncludePatterns(domain),
visibility: override?.visibility ?? 'default',
displayName: override?.displayName ?? null,
checkPath: override?.checkPath ?? null,
sortOrder: override?.sortOrder ?? null,
};
});
res.json(data);
} catch (e) {
res.status(502).json({ error: e instanceof Error ? e.message : 'upstream error' });
}
});
router.put('/hosts/:domain', (req, res) => {
const { domain } = req.params;
const body = req.body as { visibility?: Visibility; displayName?: string | null; checkPath?: string | null; sortOrder?: number | null };
if (body.visibility && !['default', 'hidden', 'shown'].includes(body.visibility)) {
res.status(400).json({ error: 'invalid visibility' });
return;
}
upsertOverride(domain, body);
res.json({ ok: true });
});
router.delete('/hosts/:domain', (req, res) => {
deleteOverride(req.params.domain);
res.json({ ok: true });
});
router.get('/manual-targets', (_req, res) => {
res.json(listManualTargets());
});
router.post('/manual-targets', (req, res) => {
const body = req.body as { name?: string; url?: string; checkPath?: string | null; sortOrder?: number | null };
if (!body.name || !body.url) {
res.status(400).json({ error: 'name and url are required' });
return;
}
res.json(createManualTarget({ name: body.name, url: body.url, checkPath: body.checkPath, sortOrder: body.sortOrder }));
});
router.put('/manual-targets/:id', (req, res) => {
const body = req.body as { name?: string; url?: string; checkPath?: string | null; enabled?: boolean; sortOrder?: number | null };
const ok = updateManualTarget(req.params.id, body);
if (!ok) { res.status(404).json({ error: 'not found' }); return; }
res.json({ ok: true });
});
router.delete('/manual-targets/:id', (req, res) => {
deleteManualTarget(req.params.id);
res.json({ ok: true });
});

274
src/adminPage.ts Normal file
View File

@@ -0,0 +1,274 @@
export function renderAdminPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>admin / status</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
:root {
--bg: #080808; --panel: #0f0f0f; --border: #1e1e1e;
--text: #d6d6d6; --dim: #555; --accent: #00e87a; --danger: #ff3300;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 13px; min-height: 100vh;
padding: 32px 16px 64px;
}
#app { width: 100%; max-width: 860px; margin: 0 auto; }
h1 { font-size: 18px; letter-spacing: 2px; color: #e8c400; margin-bottom: 24px; }
h1 span { color: var(--dim); font-weight: 400; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); margin: 28px 0 12px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 20px; }
input[type=text], input[type=password], input[type=number] {
background: #050505; border: 1px solid var(--border); color: var(--text);
font-family: inherit; font-size: 13px; padding: 6px 8px; border-radius: 4px;
}
select {
background: #050505; border: 1px solid var(--border); color: var(--text);
font-family: inherit; font-size: 13px; padding: 5px 6px; border-radius: 4px;
}
button {
background: #111; border: 1px solid var(--border); color: var(--text);
font-family: inherit; font-size: 12px; padding: 6px 12px; border-radius: 4px; cursor: pointer;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button.primary { border-color: var(--accent); color: var(--accent); }
button.danger:hover { border-color: var(--danger); color: var(--danger); }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th { text-align: left; color: var(--dim); text-transform: uppercase; font-size: 10px; letter-spacing: 0.06em;
padding: 0 8px 8px 0; border-bottom: 1px solid var(--border); }
td { padding: 8px 8px 8px 0; border-bottom: 1px solid #111; vertical-align: middle; }
.dim { color: var(--dim); }
.badge { font-size: 10px; padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border); }
.badge.yes { color: var(--accent); border-color: var(--accent); }
.badge.no { color: var(--dim); }
.row-actions { display: flex; gap: 6px; }
.error-banner { color: var(--danger); font-size: 12px; margin-top: 10px; }
.hint { color: var(--dim); font-size: 12px; }
.add-form { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.add-form input { flex: 1; min-width: 120px; }
#lock-screen { max-width: 320px; margin: 80px auto 0; text-align: center; }
#lock-screen input { width: 100%; margin-bottom: 10px; }
#lock-screen button { width: 100%; }
</style>
</head>
<body>
<div id="app">
<h1>admin<span> / status</span></h1>
<div id="lock-screen" class="card">
<p class="hint" style="margin-bottom: 14px;">admin password</p>
<input id="password" type="password" placeholder="password" autocomplete="off" />
<button id="unlock-btn" class="primary">unlock</button>
<div id="lock-error" class="error-banner"></div>
</div>
<div id="panel" style="display:none;">
<div class="card">
<p class="hint">NPM stays the source of truth for which domains exist. Overrides here are enrichment only — leave a field blank and it falls back to the NPM name / pattern-match result.</p>
</div>
<h2>npm hosts</h2>
<div class="card">
<table>
<thead><tr>
<th>domain</th><th>pattern</th><th>visibility</th><th>display name</th><th>check path</th><th></th>
</tr></thead>
<tbody id="hosts-body"><tr><td colspan="6" class="dim">loading…</td></tr></tbody>
</table>
</div>
<h2>manual targets</h2>
<div class="card">
<table>
<thead><tr>
<th>name</th><th>url</th><th>check path</th><th>enabled</th><th></th>
</tr></thead>
<tbody id="manual-body"><tr><td colspan="5" class="dim">loading…</td></tr></tbody>
</table>
<div class="add-form">
<input id="new-name" type="text" placeholder="name" />
<input id="new-url" type="text" placeholder="https://example.com" />
<input id="new-path" type="text" placeholder="check path (optional)" />
<button id="add-btn" class="primary">add</button>
</div>
<div id="manual-error" class="error-banner"></div>
</div>
</div>
</div>
<script>
(function () {
let password = sessionStorage.getItem('admin_password') || '';
function headers(extra) {
return Object.assign({ 'X-Admin-Password': password }, extra || {});
}
async function api(path, opts) {
const res = await fetch('/api/admin' + path, Object.assign({}, opts, { headers: headers((opts && opts.headers) || {}) }));
if (!res.ok) {
const body = await res.json().catch(function () { return {}; });
throw new Error(body.error || ('request failed: ' + res.status));
}
return res.status === 204 ? null : res.json();
}
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function tryUnlock() {
const input = document.getElementById('password');
const err = document.getElementById('lock-error');
password = input.value;
err.textContent = '';
try {
const body = await api('/auth/check', { method: 'POST' });
if (!body.ok) { err.textContent = 'wrong password'; return; }
sessionStorage.setItem('admin_password', password);
document.getElementById('lock-screen').style.display = 'none';
document.getElementById('panel').style.display = 'block';
loadAll();
} catch (e) {
err.textContent = e.message || 'unlock failed';
}
}
document.getElementById('unlock-btn').addEventListener('click', tryUnlock);
document.getElementById('password').addEventListener('keydown', function (e) { if (e.key === 'Enter') tryUnlock(); });
function visibilitySelect(current) {
const opts = ['default', 'hidden', 'shown'];
return '<select class="vis-select">' + opts.map(function (o) {
return '<option value="' + o + '"' + (o === current ? ' selected' : '') + '>' + o + '</option>';
}).join('') + '</select>';
}
async function loadHosts() {
const body = document.getElementById('hosts-body');
try {
const hosts = await api('/hosts');
if (!hosts.length) { body.innerHTML = '<tr><td colspan="6" class="dim">no NPM hosts found</td></tr>'; return; }
body.innerHTML = hosts.map(function (h) {
const willShow = h.visibility === 'hidden' ? false : h.visibility === 'shown' ? true : h.matchesPattern;
return '<tr data-domain="' + esc(h.domain) + '">' +
'<td>' + esc(h.domain) + (willShow ? ' <span class="badge yes">shown</span>' : ' <span class="badge no">hidden</span>') + '</td>' +
'<td><span class="badge ' + (h.matchesPattern ? 'yes' : 'no') + '">' + (h.matchesPattern ? 'match' : 'no match') + '</span></td>' +
'<td>' + visibilitySelect(h.visibility) + '</td>' +
'<td><input type="text" class="name-input" placeholder="' + esc(h.domain) + '" value="' + esc(h.displayName || '') + '" /></td>' +
'<td><input type="text" class="path-input" placeholder="/" value="' + esc(h.checkPath || '') + '" /></td>' +
'<td class="row-actions"><button class="save-btn primary">save</button></td>' +
'</tr>';
}).join('');
body.querySelectorAll('.save-btn').forEach(function (btn) {
btn.addEventListener('click', async function () {
const tr = btn.closest('tr');
const domain = tr.getAttribute('data-domain');
const visibility = tr.querySelector('.vis-select').value;
const displayName = tr.querySelector('.name-input').value.trim() || null;
const checkPath = tr.querySelector('.path-input').value.trim() || null;
btn.textContent = 'saving…';
try {
await api('/hosts/' + encodeURIComponent(domain), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visibility: visibility, displayName: displayName, checkPath: checkPath }),
});
await loadHosts();
} catch (e) {
btn.textContent = 'failed';
setTimeout(function () { btn.textContent = 'save'; }, 1500);
}
});
});
} catch (e) {
body.innerHTML = '<tr><td colspan="6" class="error-banner">' + esc(e.message) + '</td></tr>';
}
}
async function loadManual() {
const body = document.getElementById('manual-body');
try {
const targets = await api('/manual-targets');
if (!targets.length) { body.innerHTML = '<tr><td colspan="5" class="dim">no manual targets yet</td></tr>'; return; }
body.innerHTML = targets.map(function (t) {
return '<tr data-id="' + esc(t.id) + '">' +
'<td>' + esc(t.name) + '</td>' +
'<td class="dim">' + esc(t.url) + '</td>' +
'<td class="dim">' + esc(t.checkPath || '—') + '</td>' +
'<td><input type="checkbox" class="enabled-check" ' + (t.enabled ? 'checked' : '') + ' /></td>' +
'<td class="row-actions"><button class="delete-btn danger">delete</button></td>' +
'</tr>';
}).join('');
body.querySelectorAll('.enabled-check').forEach(function (cb) {
cb.addEventListener('change', async function () {
const id = cb.closest('tr').getAttribute('data-id');
await api('/manual-targets/' + encodeURIComponent(id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: cb.checked }),
});
});
});
body.querySelectorAll('.delete-btn').forEach(function (btn) {
btn.addEventListener('click', async function () {
const id = btn.closest('tr').getAttribute('data-id');
await api('/manual-targets/' + encodeURIComponent(id), { method: 'DELETE' });
await loadManual();
});
});
} catch (e) {
body.innerHTML = '<tr><td colspan="5" class="error-banner">' + esc(e.message) + '</td></tr>';
}
}
document.getElementById('add-btn').addEventListener('click', async function () {
const name = document.getElementById('new-name').value.trim();
const url = document.getElementById('new-url').value.trim();
const checkPath = document.getElementById('new-path').value.trim() || null;
const err = document.getElementById('manual-error');
err.textContent = '';
if (!name || !url) { err.textContent = 'name and url are required'; return; }
try {
await api('/manual-targets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, url: url, checkPath: checkPath }),
});
document.getElementById('new-name').value = '';
document.getElementById('new-url').value = '';
document.getElementById('new-path').value = '';
await loadManual();
} catch (e) {
err.textContent = e.message;
}
});
function loadAll() {
loadHosts();
loadManual();
}
// If a password is already remembered for this tab session, skip the lock screen.
if (password) {
api('/auth/check', { method: 'POST' }).then(function (body) {
if (body.ok) {
document.getElementById('lock-screen').style.display = 'none';
document.getElementById('panel').style.display = 'block';
loadAll();
}
}).catch(function () {});
}
})();
</script>
</body>
</html>`;
}

View File

@@ -1,4 +1,6 @@
import { fetchProxyHosts } from './npm.js'; import { fetchAllProxyHosts, matchesIncludePatterns } from './npm.js';
import { getAllOverrides, listManualTargets } from './db.js';
import { computeTargets, type CheckTarget, type RawNpmHost } from './targets.js';
export type CheckStatus = 'up' | 'down' | 'unknown'; export type CheckStatus = 'up' | 'down' | 'unknown';
@@ -22,7 +24,7 @@ const HOST_REFRESH_MS = 10 * 60_000;
let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() }; let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() };
let lastHostRefresh = 0; let lastHostRefresh = 0;
let targets: { name: string; url: string }[] = []; let rawHosts: RawNpmHost[] = [];
async function checkUrl(url: string): Promise<{ status: CheckStatus; code: number | null; latencyMs: number | null }> { async function checkUrl(url: string): Promise<{ status: CheckStatus; code: number | null; latencyMs: number | null }> {
const controller = new AbortController(); const controller = new AbortController();
@@ -39,22 +41,33 @@ async function checkUrl(url: string): Promise<{ status: CheckStatus; code: numbe
} }
} }
async function refreshHosts(): Promise<void> { // Refreshing the NPM host list hits their API and re-authenticates if
// needed, so it stays on the original 10-minute cadence. Overrides and
// manual targets are local SQLite reads — cheap enough to re-read on every
// 60s check tick, so admin changes take effect within one cycle instead of
// waiting on the NPM refresh.
async function refreshNpmHosts(): Promise<void> {
try { try {
const hosts = await fetchProxyHosts(); const hosts = await fetchAllProxyHosts();
targets = hosts.map(h => ({ rawHosts = hosts.map(h => {
name: h.domain_names[h.domain_names.length - 1], const domain = h.domain_names[h.domain_names.length - 1];
url: `https://${h.domain_names[h.domain_names.length - 1]}`, return { domain, matchesPattern: matchesIncludePatterns(domain) };
})); });
lastHostRefresh = Date.now(); lastHostRefresh = Date.now();
console.log(`[checker] loaded ${targets.length} targets from NPM`); console.log(`[checker] loaded ${rawHosts.length} candidate hosts from NPM`);
} catch (e) { } catch (e) {
console.error('[checker] failed to fetch hosts from NPM:', e); console.error('[checker] failed to fetch hosts from NPM:', e);
} }
} }
function currentTargets(): CheckTarget[] {
return computeTargets(rawHosts, getAllOverrides(), listManualTargets());
}
async function runChecks(): Promise<void> { async function runChecks(): Promise<void> {
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshHosts(); if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshNpmHosts();
const targets = currentTargets();
if (targets.length === 0) return; if (targets.length === 0) return;
const results = await Promise.all( const results = await Promise.all(
@@ -70,7 +83,7 @@ async function runChecks(): Promise<void> {
export function getStore(): StatusStore { return store; } export function getStore(): StatusStore { return store; }
export async function startChecker(): Promise<void> { export async function startChecker(): Promise<void> {
await refreshHosts(); await refreshNpmHosts();
await runChecks(); await runChecks();
setInterval(runChecks, CHECK_INTERVAL_MS); setInterval(runChecks, CHECK_INTERVAL_MS);
} }

8
src/config.ts Normal file
View File

@@ -0,0 +1,8 @@
import path from 'node:path';
export const config = {
port: parseInt(process.env.PORT ?? '3035', 10),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
// Admin UI is disabled entirely if unset — no open-by-default gate.
adminPassword: process.env.ADMIN_PASSWORD || null,
};

169
src/db.ts Normal file
View File

@@ -0,0 +1,169 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { randomBytes } from 'node:crypto';
import { config } from './config.js';
// 'default' = follow NPM_INCLUDE_PATTERNS as before; 'hidden' / 'shown'
// force the opposite of whatever the pattern would have decided.
export type Visibility = 'default' | 'hidden' | 'shown';
export interface HostOverride {
domain: string;
visibility: Visibility;
displayName: string | null;
checkPath: string | null;
sortOrder: number | null;
}
export interface ManualTarget {
id: string;
name: string;
url: string;
checkPath: string | null;
enabled: boolean;
sortOrder: number | null;
createdAt: number;
}
fs.mkdirSync(config.dataDir, { recursive: true });
export const db = new Database(path.join(config.dataDir, 'statuspage.db'));
db.pragma('journal_mode = WAL');
db.exec(`
-- NPM stays the source of truth for which domains exist. This table is
-- pure enrichment: a row only ever overrides one field at a time in
-- practice, and every field falls back to the NPM-derived default when
-- null / 'default'.
CREATE TABLE IF NOT EXISTS host_overrides (
domain TEXT PRIMARY KEY,
visibility TEXT NOT NULL DEFAULT 'default', -- 'default' | 'hidden' | 'shown'
display_name TEXT,
check_path TEXT,
sort_order INTEGER
);
CREATE TABLE IF NOT EXISTS manual_targets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
check_path TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER,
created_at INTEGER NOT NULL
);
`);
function newId(): string {
return randomBytes(9).toString('base64url');
}
export function getAllOverrides(): Map<string, HostOverride> {
const rows = db.prepare(`SELECT * FROM host_overrides`).all() as {
domain: string;
visibility: Visibility;
display_name: string | null;
check_path: string | null;
sort_order: number | null;
}[];
const map = new Map<string, HostOverride>();
for (const r of rows) {
map.set(r.domain, {
domain: r.domain,
visibility: r.visibility,
displayName: r.display_name,
checkPath: r.check_path,
sortOrder: r.sort_order,
});
}
return map;
}
export function upsertOverride(
domain: string,
fields: { visibility?: Visibility; displayName?: string | null; checkPath?: string | null; sortOrder?: number | null },
): void {
const existing = db.prepare(`SELECT * FROM host_overrides WHERE domain = ?`).get(domain) as
| { domain: string; visibility: Visibility; display_name: string | null; check_path: string | null; sort_order: number | null }
| undefined;
const next = {
visibility: fields.visibility ?? existing?.visibility ?? 'default',
display_name: fields.displayName !== undefined ? fields.displayName : (existing?.display_name ?? null),
check_path: fields.checkPath !== undefined ? fields.checkPath : (existing?.check_path ?? null),
sort_order: fields.sortOrder !== undefined ? fields.sortOrder : (existing?.sort_order ?? null),
};
db.prepare(
`INSERT INTO host_overrides (domain, visibility, display_name, check_path, sort_order)
VALUES (@domain, @visibility, @display_name, @check_path, @sort_order)
ON CONFLICT(domain) DO UPDATE SET
visibility = excluded.visibility,
display_name = excluded.display_name,
check_path = excluded.check_path,
sort_order = excluded.sort_order`,
).run({ domain, ...next });
}
export function deleteOverride(domain: string): void {
db.prepare(`DELETE FROM host_overrides WHERE domain = ?`).run(domain);
}
export function listManualTargets(): ManualTarget[] {
const rows = db.prepare(`SELECT * FROM manual_targets ORDER BY created_at ASC`).all() as {
id: string;
name: string;
url: string;
check_path: string | null;
enabled: number;
sort_order: number | null;
created_at: number;
}[];
return rows.map(r => ({
id: r.id,
name: r.name,
url: r.url,
checkPath: r.check_path,
enabled: r.enabled === 1,
sortOrder: r.sort_order,
createdAt: r.created_at,
}));
}
export function createManualTarget(fields: { name: string; url: string; checkPath?: string | null; sortOrder?: number | null }): ManualTarget {
const id = newId();
const createdAt = Math.floor(Date.now() / 1000);
db.prepare(
`INSERT INTO manual_targets (id, name, url, check_path, enabled, sort_order, created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)`,
).run(id, fields.name, fields.url, fields.checkPath ?? null, fields.sortOrder ?? null, createdAt);
return { id, name: fields.name, url: fields.url, checkPath: fields.checkPath ?? null, enabled: true, sortOrder: fields.sortOrder ?? null, createdAt };
}
export function updateManualTarget(
id: string,
fields: { name?: string; url?: string; checkPath?: string | null; enabled?: boolean; sortOrder?: number | null },
): boolean {
const existing = db.prepare(`SELECT * FROM manual_targets WHERE id = ?`).get(id) as
| { id: string; name: string; url: string; check_path: string | null; enabled: number; sort_order: number | null }
| undefined;
if (!existing) return false;
const next = {
name: fields.name ?? existing.name,
url: fields.url ?? existing.url,
check_path: fields.checkPath !== undefined ? fields.checkPath : existing.check_path,
enabled: fields.enabled !== undefined ? (fields.enabled ? 1 : 0) : existing.enabled,
sort_order: fields.sortOrder !== undefined ? fields.sortOrder : existing.sort_order,
};
db.prepare(
`UPDATE manual_targets SET name = @name, url = @url, check_path = @check_path, enabled = @enabled, sort_order = @sort_order WHERE id = @id`,
).run({ id, ...next });
return true;
}
export function deleteManualTarget(id: string): void {
db.prepare(`DELETE FROM manual_targets WHERE id = ?`).run(id);
}

View File

@@ -1,21 +1,34 @@
import express from 'express'; import express from 'express';
import { config } from './config.js';
import { startChecker, getStore } from './checker.js'; import { startChecker, getStore } from './checker.js';
import { renderPage } from './page.js'; import { renderPage } from './page.js';
import { renderAdminPage } from './adminPage.js';
import { router as adminRouter } from './admin.js';
const PORT = parseInt(process.env.PORT ?? '3035', 10);
const app = express(); const app = express();
app.use(express.json());
app.get('/api/status', (_req, res) => { app.get('/api/status', (_req, res) => {
res.json(getStore()); res.json(getStore());
}); });
app.use('/api/admin', adminRouter);
app.get('/admin', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderAdminPage());
});
app.get('/', (_req, res) => { app.get('/', (_req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderPage(getStore())); res.send(renderPage(getStore()));
}); });
app.listen(PORT, () => { app.listen(config.port, () => {
console.log(`[status] listening on :${PORT}`); console.log(`[status] listening on :${config.port}`);
if (!config.adminPassword) {
console.log('[status] ADMIN_PASSWORD not set — /admin is disabled');
}
}); });
startChecker().catch(e => { startChecker().catch(e => {

View File

@@ -11,7 +11,11 @@ const NPM_SECRET = process.env.NPM_SECRET ?? '';
const rawPatterns = (process.env.NPM_INCLUDE_PATTERNS ?? '\\.goonk\\.se$,\\.dev\\.xplwd\\.com$') const rawPatterns = (process.env.NPM_INCLUDE_PATTERNS ?? '\\.goonk\\.se$,\\.dev\\.xplwd\\.com$')
.split(',').map(p => new RegExp(p.trim())); .split(',').map(p => new RegExp(p.trim()));
const INCLUDE_PATTERNS = rawPatterns; export const INCLUDE_PATTERNS = rawPatterns;
export function matchesIncludePatterns(domain: string): boolean {
return INCLUDE_PATTERNS.some(p => p.test(domain));
}
let token: string | null = null; let token: string | null = null;
let tokenExpiry = 0; let tokenExpiry = 0;
@@ -29,14 +33,14 @@ async function getToken(): Promise<string> {
return token; return token;
} }
export async function fetchProxyHosts(): Promise<ProxyHost[]> { // Every enabled proxy host, regardless of NPM_INCLUDE_PATTERNS — the admin
// UI needs the full set to let you force-show something the pattern would
// otherwise exclude. checker.ts applies the pattern (or an override) itself.
export async function fetchAllProxyHosts(): Promise<ProxyHost[]> {
const tok = await getToken(); const tok = await getToken();
const res = await fetch(`${NPM_BASE}/api/nginx/proxy-hosts`, { const res = await fetch(`${NPM_BASE}/api/nginx/proxy-hosts`, {
headers: { Authorization: `Bearer ${tok}` }, headers: { Authorization: `Bearer ${tok}` },
}); });
const all = await res.json() as ProxyHost[]; const all = await res.json() as ProxyHost[];
return all.filter(h => return all.filter(h => h.enabled);
h.enabled &&
h.domain_names.some(d => INCLUDE_PATTERNS.some(p => p.test(d)))
);
} }

79
src/targets.ts Normal file
View File

@@ -0,0 +1,79 @@
import type { HostOverride, ManualTarget } from './db.js';
export interface CheckTarget {
key: string; // domain for NPM-derived, "manual:<id>" for manual — used for override lookups and stable identity
name: string;
url: string;
source: 'npm' | 'manual';
}
export interface RawNpmHost {
domain: string;
matchesPattern: boolean;
}
/**
* Merges NPM-discovered hosts with local overrides and manual targets into
* the final check list. Pure function — no I/O — so it's cheap to unit test
* and cheap to re-run every check tick (every 60s) even though the NPM host
* list itself is only refreshed every 10 minutes.
*
* NPM is always the source of truth for *which domains exist*; overrides
* are strictly additive enrichment. A host with no override row behaves
* exactly as it did before this feature existed: included iff it matches
* NPM_INCLUDE_PATTERNS, named after its own domain.
*/
export function computeTargets(
rawHosts: RawNpmHost[],
overrides: Map<string, HostOverride>,
manualTargets: ManualTarget[],
): CheckTarget[] {
const npmTargets: (CheckTarget & { sortOrder: number | null })[] = [];
for (const host of rawHosts) {
const override = overrides.get(host.domain);
const visibility = override?.visibility ?? 'default';
const visible =
visibility === 'hidden' ? false :
visibility === 'shown' ? true :
host.matchesPattern;
if (!visible) continue;
const name = override?.displayName || host.domain;
const checkPath = override?.checkPath || '/';
npmTargets.push({
key: host.domain,
name,
url: `https://${host.domain}${checkPath}`,
source: 'npm',
sortOrder: override?.sortOrder ?? null,
});
}
const manualTargetsMapped: (CheckTarget & { sortOrder: number | null })[] = manualTargets
.filter(t => t.enabled)
.map(t => ({
key: `manual:${t.id}`,
name: t.name,
url: t.checkPath ? `${t.url.replace(/\/$/, '')}${t.checkPath}` : t.url,
source: 'manual',
sortOrder: t.sortOrder,
}));
// Stable sort: explicit sortOrder wins (ascending), unordered entries
// keep their relative discovery order and sink after any ordered ones.
const combined = [...npmTargets, ...manualTargetsMapped];
const withIndex = combined.map((t, i) => ({ t, i }));
withIndex.sort((a, b) => {
const aHas = a.t.sortOrder != null;
const bHas = b.t.sortOrder != null;
if (aHas && bHas) return (a.t.sortOrder as number) - (b.t.sortOrder as number);
if (aHas) return -1;
if (bHas) return 1;
return a.i - b.i;
});
return withIndex.map(({ t }) => ({ key: t.key, name: t.name, url: t.url, source: t.source }));
}