Compare commits

...

2 Commits

Author SHA1 Message Date
Fredrik Johansson
edfa12b8e9 Fix: .env was never actually loaded for local dev
All checks were successful
Docker / build-and-push (push) Successful in 2m8s
README said cp .env.example .env then npm run dev, but nothing ever
read the file into process.env — no dotenv, no --env-file. This
predates the admin UI work; just surfaced by actually trying to run
it. Fixed with Node's native --env-file flag on both dev and start
(no new dependency). Doesn't affect Docker, which gets its config
from docker-compose's environment: block directly, bypassing npm
scripts entirely (CMD calls node dist/index.js directly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 15:49:56 +02:00
Fredrik Johansson
b83d7aec01 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>
2026-07-10 15:40:57 +02:00
15 changed files with 1191 additions and 26 deletions

View File

@@ -5,3 +5,7 @@ NPM_EMAIL=api@example.com
NPM_SECRET=
# Comma-separated regex patterns for which domains to include
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/
dist/
.env
data/

View File

@@ -1,5 +1,8 @@
FROM node:22-alpine AS build
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 ./
RUN npm install
COPY . .
@@ -7,8 +10,16 @@ RUN npm run build
FROM node:22-alpine
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 ./
RUN npm install --omit=dev
RUN npm install --omit=dev && apk del python3 make g++
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
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
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
4. Serves current results at `/` (HTML) and `/api/status` (JSON)
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
```bash
@@ -22,6 +37,8 @@ npm install
npm run dev
```
`.env` is loaded via Node's native `--env-file` flag (both `dev` and `start` pass it) — no `dotenv` dependency. Docker doesn't use this at all; the container gets its config from `docker-compose.yml`'s `environment:` block instead, same as before.
## Environment variables
| Variable | Default | Description |
@@ -30,7 +47,9 @@ npm run dev
| `NPM_BASE_URL` | `http://localhost:81` | Base URL of your Nginx Proxy Manager instance |
| `NPM_EMAIL` | — | NPM account email |
| `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
@@ -72,3 +91,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.
### 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_SECRET=${NPM_SECRET}
- 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",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"tsx": "^4.15.0",
@@ -459,6 +462,16 @@
"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": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -596,6 +609,57 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"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": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
@@ -620,6 +684,30 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -658,6 +746,12 @@
"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": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -703,6 +797,30 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -722,6 +840,15 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -751,6 +878,15 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -838,6 +974,15 @@
"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": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
@@ -884,6 +1029,12 @@
"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": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -920,6 +1071,12 @@
"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": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -981,6 +1138,12 @@
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1049,12 +1212,38 @@
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"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": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1133,12 +1322,45 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"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": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1148,6 +1370,18 @@
"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": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -1172,6 +1406,15 @@
"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": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1187,6 +1430,33 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1200,6 +1470,16 @@
"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": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -1240,6 +1520,35 @@
"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": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1266,6 +1575,18 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"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": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -1389,6 +1710,51 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1398,6 +1764,52 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1426,6 +1838,18 @@
"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": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1469,6 +1893,12 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1486,6 +1916,12 @@
"engines": {
"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

@@ -4,16 +4,18 @@
"license": "MIT",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "tsx watch --env-file=.env src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node --env-file=.env dist/index.js"
},
"dependencies": {
"express": "^4.19.2"
"express": "^4.19.2",
"better-sqlite3": "^11.3.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/better-sqlite3": "^7.6.11",
"tsx": "^4.15.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';
@@ -22,7 +24,7 @@ const HOST_REFRESH_MS = 10 * 60_000;
let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() };
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 }> {
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 {
const hosts = await fetchProxyHosts();
targets = hosts.map(h => ({
name: h.domain_names[h.domain_names.length - 1],
url: `https://${h.domain_names[h.domain_names.length - 1]}`,
}));
const hosts = await fetchAllProxyHosts();
rawHosts = hosts.map(h => {
const domain = h.domain_names[h.domain_names.length - 1];
return { domain, matchesPattern: matchesIncludePatterns(domain) };
});
lastHostRefresh = Date.now();
console.log(`[checker] loaded ${targets.length} targets from NPM`);
console.log(`[checker] loaded ${rawHosts.length} candidate hosts from NPM`);
} catch (e) {
console.error('[checker] failed to fetch hosts from NPM:', e);
}
}
function currentTargets(): CheckTarget[] {
return computeTargets(rawHosts, getAllOverrides(), listManualTargets());
}
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;
const results = await Promise.all(
@@ -70,7 +83,7 @@ async function runChecks(): Promise<void> {
export function getStore(): StatusStore { return store; }
export async function startChecker(): Promise<void> {
await refreshHosts();
await refreshNpmHosts();
await runChecks();
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 { config } from './config.js';
import { startChecker, getStore } from './checker.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();
app.use(express.json());
app.get('/api/status', (_req, res) => {
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) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(renderPage(getStore()));
});
app.listen(PORT, () => {
console.log(`[status] listening on :${PORT}`);
app.listen(config.port, () => {
console.log(`[status] listening on :${config.port}`);
if (!config.adminPassword) {
console.log('[status] ADMIN_PASSWORD not set — /admin is disabled');
}
});
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$')
.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 tokenExpiry = 0;
@@ -29,14 +33,14 @@ async function getToken(): Promise<string> {
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 res = await fetch(`${NPM_BASE}/api/nginx/proxy-hosts`, {
headers: { Authorization: `Bearer ${tok}` },
});
const all = await res.json() as ProxyHost[];
return all.filter(h =>
h.enabled &&
h.domain_names.some(d => INCLUDE_PATTERNS.some(p => p.test(d)))
);
return all.filter(h => h.enabled);
}

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 }));
}