Files
kb-anhonesthost/tools/screenshots/capture-admin.ts
Josh 119d376029
All checks were successful
Build and deploy / deploy (push) Successful in 24s
docs(admin): rewrite + extend WHP super-admin section from real UI
Verified every page against the live admin panel on whp01 (read-only).
Five existing articles rewritten; one new article added; customer-facing
backups article updated to match server reality.

Article changes
- overview: super admin = the root user only (no UI to add another);
  WHMCS portal route doesn't apply for admin; accurate sidebar map of
  every admin-only section; customer backups don't cover server config
  (multiple locations, not just /etc — full-server backup is the right
  safety net).
- server-settings: walked all six tabs (System / Services / Mail / DNS
  / Network & SSL / Security); clarified that host Apache + PHP-FPM
  serve the WHP control panel, not customer sites; that MySQL runs as
  a container so host MySQL config is client-facing; that custom
  container needs are met by publishing a custom Docker image (linked
  to repo.anhonesthost.net/cloud-hosting-platform/ for examples).
- coraza-waf: real Firing rules / CRS catalog / Activity tabs; global
  WAF mode pill (off/detect/enforce); per-rule + per-host overrides;
  Ask AI link; security.db source-of-truth + SIGHUP reload note.
- site-monitoring: split into the three actual admin pages — AI Monitor
  dashboard, Issues, Ignore Rules — with stat tiles + health-check
  timeline + ignore-rule AND-semantics.
- user-management: account types corrected to full / domain_dns /
  mail_dns (verified in web-files/pages/user-management.php:26);
  system users are protected against deletion (verified is_protected_user
  in web-files/libs/usermgmt.php:697); delegated users are admin-editable
  (not read-only); suspension page is served by haproxy's 503 errorfile
  (verified in haproxy-manager-base/haproxy_tarpit_config.txt:31) so
  troubleshooting points at haproxy reload / container logs.
- new admin/backups: customer-data backups vs full-server backups;
  auto-backups only run with a default target; how to add global vs
  per-customer targets; how to fire on-demand backups for any user;
  troubleshooting around missing targets / failed test / disk pressure.
- how-to/backups (customer): aside about default-target requirement;
  new section explaining what full-server backups cover vs customer
  backups (managed plans + VDS covered by AnHonestHost; elsewhere is
  the server operator's responsibility).

New components / tooling
- admin-signin partial: 'sign in directly at :8443 as root'.
- Head.astro override + medium-zoom: click-to-zoom lightbox on every
  article image; auto-reattaches after Starlight client navigation.
- capture-admin.ts: read-only Playwright capture for admin docs with
  multi-pass redaction (server hostnames, mail server, customer
  domains, customer usernames in table cells, IPs except RFC1918 and
  public resolvers, password/key/token/secret/api input values, plus
  LiteLLM URLs, model names, JWT/sk-prefix API keys, root → admin).
2026-05-18 10:49:43 -07:00

230 lines
10 KiB
TypeScript

/**
* v2 admin capture — stricter redaction and deeper navigation.
*
* - Masks customer domains (anything not in the brand allowlist).
* - Masks customer-shaped usernames (anything not in the system allowlist).
* - Masks input value attributes (the v1 only walked text nodes).
* - Captures Settings sub-tabs (System, Services, Mail, DNS, Network & SSL,
* Security) since those are where LiteLLM URL / model / key likely live.
*
* Read-only. Never clicks save/apply/restart/delete.
*/
import { chromium, type Page } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/whp');
function need(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`missing env: ${name}`);
return v;
}
const BASE = need('WHP_BASE');
const USER = need('WHP_ADMIN_USER');
const PASS = need('WHP_ADMIN_PASS');
const HIDE_CSS = `.navbar-text, .brand-full { visibility: hidden !important; }`;
async function login(page: Page) {
await page.goto(`${BASE}/login.php`, { waitUntil: 'domcontentloaded' });
await page.fill('input[name="user"]', USER);
await page.fill('input[name="password"]', PASS);
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
}
async function redact(page: Page) {
await page.addStyleTag({ content: HIDE_CSS });
await page.evaluate(() => {
// ---- Mask all secret-shaped input values ----
const secretInputSel = [
'input[type="password"]',
'input[name*="key" i]',
'input[name*="token" i]',
'input[name*="secret" i]',
'input[name*="api" i]',
];
for (const sel of secretInputSel) {
document.querySelectorAll<HTMLInputElement>(sel).forEach((el) => {
if (el.value) el.value = '████████████████';
});
}
// ---- Brand allowlist ----
const BRAND_SUFFIXES = [
'anhonesthost.com', 'anhonesthost.net', 'anhonesthost.io',
'anhh.co',
'cloud-hosting.io',
'example.com', 'example.org', 'example.net',
];
// ---- System users to keep visible (others get masked) ----
const SYSTEM_USERS_ARR = ['root', 'admin', 'whp', 'haproxy', 'apache', 'nginx', 'newuser'];
// ---- Text-node swaps ----
const swaps: [RegExp, string][] = [
// Server / mail / nameserver hostnames in our infra
[/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '<your-server>.cloud-hosting.io'],
[/mail\d+\.cloud-hosting\.io/gi, '<your-mail-server>.cloud-hosting.io'],
[/whp\d+(-[a-z0-9]+)?\b/gi, '<your-server>'],
[/WHP\d+(-[A-Z0-9]+)?\b/g, '<YOUR-SERVER>'],
// IPs
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<server-IP>'],
// Home dirs
[/\/docker\/users\/[a-z0-9-]+/g, '/docker/users/<user>'],
// Common secret shapes
[/sk-[A-Za-z0-9_-]{20,}/g, '<API-KEY>'],
[/sk_(test|live)_[A-Za-z0-9]{20,}/g, '<API-KEY>'],
[/Bearer\s+[A-Za-z0-9._-]{20,}/g, 'Bearer <API-KEY>'],
[/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g, '<JWT>'],
// AI provider URLs
[/https?:\/\/[^\s"'<>]*litellm[^\s"'<>]*/gi, '<litellm-endpoint>'],
[/https?:\/\/[^\s"'<>]*\.anhonesthost\.(net|com|io)[^\s"'<>]*/gi, '<internal-endpoint>'],
// Model family identifiers
[/(claude|gpt|llama|mistral|gemini)[a-z0-9._-]*-\d[a-z0-9.\-]*/gi, '<model-name>'],
// Bichon / Coraza / haproxy internal endpoints
[/https?:\/\/[^\s"'<>]*\b(bichon|coraza-spoa|haproxy-manager)[^\s"'<>]*/gi, '<internal-service>'],
// root → admin (we don't expose which UNIX user has super admin)
[/Welcome, root\b/g, 'Welcome, admin'],
[/(User:\s*)root\b/g, '$1admin'],
[/(Home Directory:\s*)\/root\b/g, '$1/<admin-home>'],
// Standalone 'root' (whole-word, not preceded by / or . — so paths like
// /root/foo and references like .root stay untouched).
[/(^|[^/.\w])root\b/g, '$1admin'],
];
// ---- Walk text nodes ----
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n: Node | null = walker.nextNode();
while (n) { nodes.push(n as Text); n = walker.nextNode(); }
for (const node of nodes) {
let v = node.nodeValue ?? '';
// Mask non-brand domain names (basic pattern: word.tld)
v = v.replace(/\b([a-z0-9][a-z0-9-]{0,62}\.)+[a-z]{2,24}\b/gi, function (m) {
const h = m.toLowerCase();
let brand = false;
for (const s of BRAND_SUFFIXES) { if (h === s || h.endsWith('.' + s)) { brand = true; break; } }
return brand ? m : '<customer-domain>';
});
// Apply other swaps
for (const [re, replacement] of swaps) v = v.replace(re, replacement);
if (v !== node.nodeValue) node.nodeValue = v;
}
// ---- Mask sensitive content in input values ----
document.querySelectorAll<HTMLInputElement>('input[type="text"], input[type="url"], input[type="email"], input:not([type])').forEach((el) => {
if (!el.value) return;
const v = el.value;
let nv = v.replace(/\b([a-z0-9][a-z0-9-]{0,62}\.)+[a-z]{2,24}\b/gi, function (m) {
const h = m.toLowerCase();
let brand = false;
for (const s of BRAND_SUFFIXES) { if (h === s || h.endsWith('.' + s)) { brand = true; break; } }
return brand ? m : '<customer-domain>';
});
// Server / mail / NS hostnames
nv = nv.replace(/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '<your-server>.cloud-hosting.io');
nv = nv.replace(/mail\d+\.cloud-hosting\.io/gi, '<your-mail-server>.cloud-hosting.io');
nv = nv.replace(/ns[12]\.whp\d+\.cloud-hosting\.io/gi, 'ns<n>.<your-server>.cloud-hosting.io');
// IPv4 — skip the well-known public resolvers / RFC1918 examples
nv = nv.replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, function (ip) {
const pub = new Set(['1.1.1.1', '1.0.0.1', '8.8.8.8', '8.8.4.4', '9.9.9.9', '149.112.112.112', '208.67.222.222', '208.67.220.220']);
if (pub.has(ip)) return ip;
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip)) return ip; // private nets, fine
return '<server-IP>';
});
if (nv !== v) el.value = nv;
});
// ---- Mask customer usernames in table cells ----
// Heuristic: find table cells under a header containing 'user', 'username', or 'target' (case-insensitive)
document.querySelectorAll('table').forEach((tbl) => {
const headers = Array.from(tbl.querySelectorAll('thead th, thead td')).map(th => (th.textContent || '').trim().toLowerCase());
const userCols: number[] = [];
for (let i = 0; i < headers.length; i++) {
if (/^(user(name)?|target|owner|account)$/.test(headers[i])) userCols.push(i);
}
if (userCols.length === 0) return;
const rows = tbl.querySelectorAll('tbody tr');
for (let r = 0; r < rows.length; r++) {
const cells = rows[r].querySelectorAll('td');
for (const idx of userCols) {
const cell = cells[idx];
if (!cell) continue;
const txt = (cell.textContent || '').trim();
if (!txt) continue;
let isSystem = false;
for (const u of SYSTEM_USERS_ARR) { if (u === txt) { isSystem = true; break; } }
if (!isSystem) cell.textContent = '<user>';
}
}
});
});
}
async function shot(page: Page, id: string) {
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
await redact(page);
const path = resolve(OUT_DIR, `${id}.png`);
await page.screenshot({ path, fullPage: false });
console.log(`captured ${id}`);
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
const page = await ctx.newPage();
try {
await login(page);
// Server Settings — click each tab
await page.goto(`${BASE}/index.php?page=server-settings`);
await page.waitForLoadState('networkidle');
await shot(page, 'admin-srvset-system');
for (const tab of ['services', 'mail', 'dns', 'network', 'security']) {
const trigger = page.locator(`[data-bs-target="#tab-${tab}"]`).first();
if (await trigger.count() === 0) { console.log('tab trigger not found:', tab); continue; }
await trigger.click().catch(()=>{});
await page.waitForTimeout(1200);
await shot(page, `admin-srvset-${tab}`);
}
// Re-shoot the previously PII-heavy pages with v2 redaction
const rerun = [
{ id: 'admin-coraza', path: '/index.php?page=coraza-rules' },
{ id: 'admin-monitor-admin', path: '/index.php?page=ai-monitor' },
{ id: 'admin-ignore-rules', path: '/index.php?page=ai-monitor-ignore-rules' },
{ id: 'admin-user-mgmt', path: '/index.php?page=user-management' },
{ id: 'admin-user-resources', path: '/index.php?page=user-resources' },
{ id: 'admin-issues', path: '/index.php?page=issues' },
{ id: 'admin-suspensions', path: '/index.php?page=account-suspensions' },
{ id: 'admin-disk-usage', path: '/index.php?page=disk-usage' },
{ id: 'admin-docker', path: '/index.php?page=docker-management' },
{ id: 'admin-valkey', path: '/index.php?page=valkey-admin' },
{ id: 'admin-updates', path: '/index.php?page=update-management' },
{ id: 'admin-container-boot', path: '/index.php?page=container-boot' },
{ id: 'admin-site-audit', path: '/index.php?page=site-audit' },
{ id: 'admin-delegated', path: '/index.php?page=delegated-users' },
];
for (const r of rerun) {
await page.goto(`${BASE}${r.path}`);
await page.waitForLoadState('networkidle');
await shot(page, r.id);
}
} finally {
await browser.close();
}
}
main().catch(e => { console.error(e); process.exit(1); });