Initial commit — status checker service
All checks were successful
Docker / build-and-push (push) Successful in 35s

Polls Nginx Proxy Manager API for proxy hosts matching configurable
domain patterns (NPM_INCLUDE_PATTERNS), HTTP-checks each every 60s,
serves results as JSON (/api/status) and a terminal-aesthetic HTML
page (/). Port 3035.

NPM connection and domain filter patterns are fully configurable via
environment variables — see README and .env.example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-07 21:36:43 +02:00
commit a300ea1b43
13 changed files with 1922 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
IMAGE=registry.example.com/youruser/status:latest
PORT=3035
NPM_BASE_URL=http://proxy.example.com:81
NPM_EMAIL=api@example.com
NPM_SECRET=
# Comma-separated regex patterns for which domains to include
NPM_INCLUDE_PATTERNS=\.example\.com$,\.dev\.example\.com$

View File

@@ -0,0 +1,51 @@
name: Docker
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
fi
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.host }}
username: ${{ github.actor }}
password: ${{ secrets.TKNTKN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.env

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3035
CMD ["node", "dist/index.js"]

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# status
A lightweight HTTP status checker that sources its target list from Nginx Proxy Manager and serves results as JSON and a terminal-aesthetic HTML page.
Designed to run as a standalone public status page on its own subdomain — if your main site is down, the status page is still reachable independently.
## 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`
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.
## Running locally
```bash
cp .env.example .env
# fill in NPM_EMAIL and NPM_SECRET
npm install
npm run dev
```
## Environment variables
| Variable | Default | Description |
|---|---|---|
| `PORT` | `3035` | Port to listen on |
| `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 |
## Docker
```bash
docker compose up -d
```
Uses the image from `IMAGE` in `.env`. For a local build:
```bash
docker build -t status:local .
IMAGE=status:local docker compose up -d
```
## CI/CD
Gitea Actions workflow at `.gitea/workflows/build.yml`. Builds and pushes to the Gitea container registry on every push to `main`. Requires a `TKNTKN` secret (PAT with `packages:write`) in the repo settings.
## API
```
GET /api/status
```
```json
{
"lastRefreshed": "2026-07-07T19:35:43Z",
"hosts": [
{
"name": "example.com",
"url": "https://example.com",
"status": "up",
"statusCode": 200,
"latencyMs": 182,
"checkedAt": "2026-07-07T19:35:43Z"
}
]
}
```
`status` is `"up"` for any response (including 4xx — the server is reachable), `"down"` for connection errors and 5xx, `"unknown"` before the first check completes.

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
app:
image: ${IMAGE}
restart: unless-stopped
ports:
- "${PORT:-3035}:3035"
environment:
- NPM_BASE_URL=${NPM_BASE_URL}
- NPM_EMAIL=${NPM_EMAIL}
- NPM_SECRET=${NPM_SECRET}
- NPM_INCLUDE_PATTERNS=${NPM_INCLUDE_PATTERNS}

1491
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "status",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
}
}

76
src/checker.ts Normal file
View File

@@ -0,0 +1,76 @@
import { fetchProxyHosts } from './npm.js';
export type CheckStatus = 'up' | 'down' | 'unknown';
export interface HostResult {
name: string;
url: string;
status: CheckStatus;
statusCode: number | null;
latencyMs: number | null;
checkedAt: string;
}
export interface StatusStore {
hosts: HostResult[];
lastRefreshed: string;
}
const TIMEOUT_MS = 8_000;
const CHECK_INTERVAL_MS = 60_000;
const HOST_REFRESH_MS = 10 * 60_000;
let store: StatusStore = { hosts: [], lastRefreshed: new Date().toISOString() };
let lastHostRefresh = 0;
let targets: { name: string; url: string }[] = [];
async function checkUrl(url: string): Promise<{ status: CheckStatus; code: number | null; latencyMs: number | null }> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = Date.now();
try {
const res = await fetch(url, { signal: controller.signal, redirect: 'follow' });
const latencyMs = Date.now() - start;
clearTimeout(timer);
return { status: res.ok || res.status < 500 ? 'up' : 'down', code: res.status, latencyMs };
} catch {
clearTimeout(timer);
return { status: 'down', code: null, latencyMs: null };
}
}
async function refreshHosts(): 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]}`,
}));
lastHostRefresh = Date.now();
console.log(`[checker] loaded ${targets.length} targets from NPM`);
} catch (e) {
console.error('[checker] failed to fetch hosts from NPM:', e);
}
}
async function runChecks(): Promise<void> {
if (Date.now() - lastHostRefresh > HOST_REFRESH_MS) await refreshHosts();
if (targets.length === 0) return;
const results = await Promise.all(
targets.map(async t => {
const r = await checkUrl(t.url);
return { name: t.name, url: t.url, status: r.status, statusCode: r.code, latencyMs: r.latencyMs, checkedAt: new Date().toISOString() } satisfies HostResult;
})
);
store = { hosts: results, lastRefreshed: new Date().toISOString() };
}
export function getStore(): StatusStore { return store; }
export async function startChecker(): Promise<void> {
await refreshHosts();
await runChecks();
setInterval(runChecks, CHECK_INTERVAL_MS);
}

24
src/index.ts Normal file
View File

@@ -0,0 +1,24 @@
import express from 'express';
import { startChecker, getStore } from './checker.js';
import { renderPage } from './page.js';
const PORT = parseInt(process.env.PORT ?? '3035', 10);
const app = express();
app.get('/api/status', (_req, res) => {
res.json(getStore());
});
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}`);
});
startChecker().catch(e => {
console.error('[status] checker failed to start:', e);
process.exit(1);
});

42
src/npm.ts Normal file
View File

@@ -0,0 +1,42 @@
export interface ProxyHost {
domain_names: string[];
forward_host: string;
forward_port: number;
enabled: boolean;
}
const NPM_BASE = process.env.NPM_BASE_URL ?? 'http://localhost:81';
const NPM_EMAIL = process.env.NPM_EMAIL ?? '';
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;
let token: string | null = null;
let tokenExpiry = 0;
async function getToken(): Promise<string> {
if (token && Date.now() < tokenExpiry) return token;
const res = await fetch(`${NPM_BASE}/api/tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identity: NPM_EMAIL, secret: NPM_SECRET }),
});
const data = await res.json() as { token: string; expires: string };
token = data.token;
tokenExpiry = new Date(data.expires).getTime() - 60_000;
return token;
}
export async function fetchProxyHosts(): 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)))
);
}

98
src/page.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { StatusStore } from './checker.js';
export function renderPage(store: StatusStore): string {
const upCount = store.hosts.filter(h => h.status === 'up').length;
const downCount = store.hosts.filter(h => h.status === 'down').length;
const allUp = downCount === 0 && store.hosts.length > 0;
const rows = store.hosts.map(h => {
const cls = h.status === 'up' ? 'up' : h.status === 'down' ? 'down' : 'unknown';
const indicator = h.status === 'up' ? '●' : h.status === 'down' ? '●' : '○';
const latency = h.latencyMs != null ? `${h.latencyMs}ms` : '—';
const code = h.statusCode != null ? String(h.statusCode) : '—';
return `<tr>
<td><span class="dot ${cls}">${indicator}</span></td>
<td><a href="${h.url}" target="_blank" rel="noopener">${h.name}</a></td>
<td class="dim">${code}</td>
<td class="dim">${latency}</td>
</tr>`;
}).join('\n');
const summaryClass = allUp ? 'up' : downCount > 0 ? 'down' : 'unknown';
const summaryText = store.hosts.length === 0
? 'checking…'
: allUp
? 'all systems operational'
: `${downCount} host${downCount !== 1 ? 's' : ''} down`;
const checked = new Date(store.lastRefreshed).toUTCString();
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="60">
<title>status / goonk</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;
--up: #00e87a; --down: #ff3300; --unknown: #888;
}
*, *::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: 14px; min-height: 100vh;
display: flex; justify-content: center; align-items: flex-start;
padding: 32px 16px 64px;
}
#app { width: 100%; max-width: 640px; }
header { border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 20px;
display: flex; justify-content: space-between; align-items: baseline; }
.brand { font-size: 18px; font-weight: 700; letter-spacing: 2px; color: #e8c400; }
.brand span { color: var(--dim); font-weight: 400; }
.summary { font-size: 13px; }
.summary.up { color: var(--up); }
.summary.down { color: var(--down); }
.summary.unknown { color: var(--unknown); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 11px; color: var(--dim); text-transform: uppercase;
letter-spacing: 0.08em; padding: 0 8px 8px 0; border-bottom: 1px solid var(--border); }
td { padding: 9px 8px 9px 0; border-bottom: 1px solid #111; vertical-align: middle; }
td a { color: var(--text); text-decoration: none; }
td a:hover { color: var(--up); }
.dim { color: var(--dim); font-size: 12px; }
.dot { font-size: 10px; }
.dot.up { color: var(--up); }
.dot.down { color: var(--down); }
.dot.unknown { color: var(--unknown); }
footer { margin-top: 20px; font-size: 11px; color: var(--dim);
display: flex; justify-content: space-between; border-top: 1px solid var(--border); padding-top: 10px; }
</style>
</head>
<body>
<div id="app">
<header>
<div class="brand">status<span> / goonk</span></div>
<div class="summary ${summaryClass}">${summaryText}</div>
</header>
<table>
<thead><tr>
<th style="width:24px"></th>
<th>host</th>
<th style="width:60px">code</th>
<th style="width:80px">latency</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
<footer>
<span>refreshes every 60s</span>
<span>checked ${checked}</span>
</footer>
</div>
</body>
</html>`;
}

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}