All checks were successful
Build and deploy / deploy (push) Successful in 24s
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).
230 lines
10 KiB
TypeScript
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); });
|