feat(screenshots): real netOrk screenshots from an anonymized demo copy

The site has shown hand-built JSX mockups of the UI so far. This adds the
tooling to replace them with screenshots of the real application:

- scripts/demo/up.sh restores a pg_dump of a production database into a
  local Postgres and starts netOrk (a pinned release, default v0.28.0) with
  only the API and the UI: no worker, no beat, no Redis, a random encryption
  key. Nothing polls and nothing can reach a device.
- scripts/demo/anonymize.py rewrites every text, JSON and address column of
  every table: domains to example.demo, private IPv4 per /16 with the host
  part kept, public addresses into the documentation ranges, MACs with the
  vendor prefix kept, e-mail addresses and configured names. Secrets are
  emptied by column name, one admin "netork" is left. It refuses non-local
  databases and ends with a leak report. The real-to-demo name map lives
  outside the repo.
- scripts/screenshots/capture.py drives headless Chromium through a
  declarative list of pages, logs in to the demo copy by itself, and aborts
  every non-GET API request, so taking screenshots cannot change anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-09-26 07:33:18 +02:00
co-authored by Claude Opus 5.5
parent 4cba6e156c
commit 38834100d1
6 changed files with 721 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Demo instance for screenshots
The website shows real netOrk screens, taken from a local copy of a production
database with every hostname, domain, address, MAC and name replaced.
```
pg_dump -Fc ... > netork.dump # on the production host, by hand
scripts/demo/up.sh restore netork.dump # fresh local DB + anonymize.py
scripts/demo/up.sh start # API :8000, UI http://127.0.0.1:5173
scripts/screenshots/capture.py --list-devices
scripts/screenshots/capture.py --var ap=<id> --var switch=<id> --var server=<id>
```
Log in as `netork` / `netork-demo`.
- Only the API and the UI run. There is no worker, no beat and no Redis, so
nothing polls or reaches a device. Stored credentials are emptied, and the
encryption key is random per start.
- The mapping from real to demo names lives outside the repo in
`~/.config/netork-screenshots/demo-map.json`, because it lists the real names.
Domains become `example.demo`.
- `anonymize.py` ends with a leak report. Read it before taking screenshots,
and look at every image before committing it.
- The dump file itself holds production data: keep it out of the repo and
delete it when done.
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""Turn a restored copy of a production netOrk database into demo data.
anonymize.py [--dsn postgresql://...] [--map demo-map.json] [--dry-run]
Run it against the LOCAL copy only; it refuses anything that is not
localhost. It works on every text-like column of every table instead of a
hand-kept list, so a table added in a later release is covered too:
* domains every configured domain (e.g. corp.example.com, acme.io) becomes
`example.demo`, subdomains kept: gw.home.corp.example.com ->
gw.home.example.demo
* IPv4 private addresses move to another /16 per /16, host part kept,
so subnets and VLAN plans still line up; public addresses are
mapped one by one into the documentation ranges
* IPv6 global prefixes go to 2001:db8::/32, interface IDs are hashed
* MAC the vendor prefix (OUI) is kept, so manufacturer lookups still
work; the device part is hashed
* e-mail local part hashed, domain example.demo
* names hostnames, site names, VLAN names, user names ... from the map
* secrets stored credentials, keys, tokens, TOTP and secret settings are
emptied; one admin `netork` with a known password is left
Every mapping is deterministic, so the same address always turns into the
same fake one, across tables, JSON documents and log lines alike. At the end
a leak report lists anything that still looks like the original.
"""
import argparse
import asyncio
import hashlib
import ipaddress
import json
import os
import re
import sys
from pathlib import Path
import asyncpg
DEFAULT_DSN = "postgresql://netork:demo@127.0.0.1:55432/netork"
DEFAULT_MAP = Path.home() / ".config" / "netork-screenshots" / "demo-map.json"
DEMO_DOMAIN = "example.demo"
# Public reference data: large, and nothing in it is about the instance.
SKIP_TABLES = {
"alembic_version", "cwe_entries", "epss_scores", "nvd_cpe_matches",
"nvd_cpe_products", "nvd_cve_requirements", "nvd_cves", "osv_affected",
"osv_vulns", "oui_vendors", "service_templates",
}
TEXT_TYPES = {"text", "character varying", "jsonb", "json", "inet", "cidr", "macaddr", "ARRAY"}
# Well-known public resolvers stay as they are; they say nothing about anyone.
KEEP_PUBLIC = {"1.1.1.1", "1.0.0.1", "8.8.8.8", "8.8.4.4", "9.9.9.9", "149.112.112.112"}
IPV4 = re.compile(r"(?<![\d.])((?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})(?!\d|\.\d)")
MAC = re.compile(r"(?<![0-9A-Fa-f:-])([0-9A-Fa-f]{2}([:-])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2})(?![0-9A-Fa-f:-])")
MAC_DOT = re.compile(r"(?<![0-9A-Fa-f.])([0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4})(?![0-9A-Fa-f.])")
IPV6 = re.compile(r"(?<![0-9A-Fa-f:])((?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4})(?![0-9A-Fa-f:])")
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@([A-Za-z0-9-]+\.)+[A-Za-z]{2,}")
def h(value: str, n: int) -> str:
return hashlib.sha256(value.encode()).hexdigest()[:n]
class Mapper:
def __init__(self, cfg: dict):
# {"home.corp.example.com": "hq.example.demo", "corp.example.com": "example.demo"}
self.domains: dict[str, str] = cfg.get("domains", {})
self.prefix16 = dict(cfg.get("ipv4_prefix16", {}))
self.pool16 = iter(cfg.get("ipv4_pool16", [f"10.{n}" for n in range(20, 250, 10)]))
self.public: dict[str, str] = {}
# Public-looking dotted quads are only mapped once they were seen as an
# address (see collect_public); "kernel 6.8.0.45" is a version, not a host.
self.known_public: set[str] = set(cfg.get("public_ips", []))
self.unmapped_public: dict[str, int] = {}
self.public_pool = iter(
[f"203.0.113.{n}" for n in range(10, 250)] + [f"198.51.100.{n}" for n in range(10, 250)])
names = {**cfg.get("hostnames", {}), **cfg.get("terms", {})}
self.names = names
self.names_re = None
if names:
alt = "|".join(re.escape(k) for k in sorted(names, key=len, reverse=True))
# A name is a whole token: not glued to letters, digits, '-' or '_'.
self.names_re = re.compile(rf"(?<![\w-])({alt})(?![\w-])")
self.domain_re = None
if self.domains:
alt = "|".join(re.escape(d) for d in sorted(self.domains, key=len, reverse=True))
# Lazy prefix, so the longest configured domain wins.
self.domain_re = re.compile(rf"(?<![\w-])((?:[\w-]+\.)*?)({alt})(?![\w-])", re.I)
# -- single values -------------------------------------------------------
def ipv4(self, ip: str) -> str:
a = ipaddress.IPv4Address(ip)
if ip in KEEP_PUBLIC or a.is_loopback or a.is_multicast or a.is_unspecified \
or a.is_link_local or ip.startswith("255.") or a.is_reserved:
return ip
if a.is_private:
p = ".".join(ip.split(".")[:2])
if p not in self.prefix16:
self.prefix16[p] = next(self.pool16)
return self.prefix16[p] + "." + ".".join(ip.split(".")[2:])
if ip not in self.known_public:
self.unmapped_public[ip] = self.unmapped_public.get(ip, 0) + 1
return ip
if ip not in self.public:
self.public[ip] = next(self.public_pool)
return self.public[ip]
def mac(self, m: str) -> str:
sep = m[2]
hexs = m.replace(sep, "")
new = hexs[:6] + h(hexs.lower(), 6)
new = new.upper() if hexs.isupper() else new.lower()
return sep.join(new[i:i + 2] for i in range(0, 12, 2))
def mac_dot(self, m: str) -> str:
hexs = m.replace(".", "")
new = hexs[:6] + h(hexs.lower(), 6)
return ".".join(new[i:i + 4] for i in range(0, 12, 4))
def ipv6(self, s: str) -> str:
# "Data::" or "12:30:45" are no addresses; demand three real groups.
if sum(1 for g in s.split(":") if g) < 3:
return s
try:
a = ipaddress.IPv6Address(s)
except ValueError:
return s # a time like 12:30:45 or similar, not an address
if a.is_loopback or a.is_unspecified or a.is_multicast:
return s
iid = h(a.packed[8:].hex(), 16)
if a.is_link_local:
prefix = "fe80:0000:0000:0000"
elif a.is_private: # ULA fd00::/8, keep it ULA
prefix = "fd00:" + h(a.packed[:8].hex(), 12)
prefix = prefix[:4] + ":" + prefix[5:9] + ":" + prefix[9:13] + ":" + prefix[13:17].ljust(4, "0")
else:
p = h(a.packed[:8].hex(), 8)
prefix = f"2001:0db8:{p[:4]}:{p[4:]}"
full = prefix + ":" + ":".join(iid[i:i + 4] for i in range(0, 16, 4))
return str(ipaddress.IPv6Address(full))
def email(self, m: re.Match) -> str:
e = m.group(0)
if e.endswith("@" + DEMO_DOMAIN):
return e
return f"user-{h(e.lower(), 6)}@{DEMO_DOMAIN}"
# -- whole strings -------------------------------------------------------
def text(self, s: str) -> str:
s = EMAIL.sub(self.email, s)
if self.domain_re:
s = self.domain_re.sub(lambda m: m.group(1) + self.domains[m.group(2).lower()], s)
s = MAC.sub(lambda m: self.mac(m.group(1)), s)
s = MAC_DOT.sub(lambda m: self.mac_dot(m.group(1)), s)
s = IPV6.sub(lambda m: self.ipv6(m.group(1)), s)
s = IPV4.sub(lambda m: self.ipv4(m.group(1)), s)
if self.names_re:
s = self.names_re.sub(lambda m: self.names[m.group(1)], s)
return s
# Cheap server-side prefilter: only rows that could contain something to map.
def prefilter(cfg: dict) -> str:
parts = [r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", r"[0-9A-Fa-f]{2}[:-][0-9A-Fa-f]{2}[:-]",
r"[0-9A-Fa-f]{4}\.[0-9A-Fa-f]{4}\.", r"[0-9A-Fa-f]{1,4}::?[0-9A-Fa-f]{1,4}:", "@"]
for k in [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {})]:
parts.append(re.escape(k))
return "|".join(parts)
# Columns emptied wherever they occur, found by name so a new table is covered.
SECRET_COLUMN = re.compile(r"(password|secret|private_key|api_key|apikey|token)", re.I)
SECRET_KEEP = {"hashed_password", "token_version", "title_tokens", "disable_password_auth"}
# Whole tables that only hold secrets or personal delivery data.
SECRET_TABLES = ["user_ssh_keys", "user_backup_codes", "notification_deliveries",
"notification_mutes", "notification_channels", "trusted_networks"]
async def columns(con) -> list[tuple[str, str, str]]:
rows = await con.fetch(
"SELECT table_name, column_name, data_type FROM information_schema.columns "
"WHERE table_schema = 'public' ORDER BY table_name, ordinal_position")
return [(r[0], r[1], r[2]) for r in rows
if r[0] not in SKIP_TABLES and r[2] in TEXT_TYPES]
ADDRESS_COLUMN = re.compile(r"(^|_)(ip|ips|ip_address|address|addr|host|target|source|wan|gateway|peer|value)(_|$)")
QUAD = r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"
# A dotted quad reads as an address when it is a whole JSON string value (not
# under a version-like key) or follows a word that introduces an address.
AS_JSON_VALUE = re.compile(rf'(?:"([^"]*)"\s*:\s*)?"({QUAD})(?:/\d{{1,2}})?"')
AS_PROSE = re.compile(
rf"(?i)\b(?:from|to|ip|ipv4|addr|address|host|src|dst|source|peer|wan|gateway|gw|via|at|by|nameserver|server)\W{{1,3}}({QUAD})")
VERSIONISH = re.compile(r"(?i)version|ver$|release|build|firmware|kernel|rev")
def addresses_in(value: str, whole_column: bool) -> set[str]:
found = set()
if whole_column:
found.update(IPV4.findall(value))
for key, ip in AS_JSON_VALUE.findall(value):
if not (key and VERSIONISH.search(key)):
found.add(ip)
found.update(AS_PROSE.findall(value))
return found
async def collect_public(con, mapper: Mapper) -> None:
"""Learn which public IPv4 addresses really are addresses."""
for t, c, dt in await columns(con):
whole = dt in ("inet", "cidr") or bool(ADDRESS_COLUMN.search(c))
rows = await con.fetch(
f'SELECT DISTINCT "{c}"::text AS v FROM "{t}" WHERE "{c}"::text ~ $1', QUAD)
for r in rows:
for ip in addresses_in(r["v"], whole):
try:
a = ipaddress.IPv4Address(ip)
except ValueError:
continue
if a.is_global and ip not in KEEP_PUBLIC:
mapper.known_public.add(ip)
async def scrub_secrets(con, dry: bool) -> None:
rows = await con.fetch(
"SELECT c.table_name, c.column_name, c.is_nullable, c.data_type "
"FROM information_schema.columns c JOIN information_schema.tables t "
"ON t.table_name = c.table_name AND t.table_schema = c.table_schema "
"WHERE c.table_schema = 'public' AND t.table_type = 'BASE TABLE'")
for t, c, nullable, dt in rows:
if t in SKIP_TABLES or c in SECRET_KEEP or not SECRET_COLUMN.search(c):
continue
if dt not in ("text", "character varying", "jsonb", "json", "bytea"):
continue # flags like require_password are booleans
value = "NULL" if nullable == "YES" else ("'{}'" if dt in ("jsonb", "json") else "''")
if dt == "bytea" and nullable != "YES":
value = "''::bytea"
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}" IS NOT NULL')
if n:
print(f" {t}.{c}: {n} emptied")
if not dry:
await con.execute(f'UPDATE "{t}" SET "{c}" = {value}')
# Settings flagged secret keep their key, lose their value.
if await con.fetchval("SELECT to_regclass('public.settings') IS NOT NULL"):
n = await con.fetchval("SELECT count(*) FROM settings WHERE is_secret")
print(f" settings: {n} secret values emptied")
if not dry:
await con.execute("UPDATE settings SET value = '' WHERE is_secret")
for t in SECRET_TABLES:
if await con.fetchval("SELECT to_regclass($1) IS NOT NULL", f"public.{t}"):
n = await con.fetchval(f'SELECT count(*) FROM "{t}"')
print(f" {t}: {n} rows deleted")
if not dry:
await con.execute(f'DELETE FROM "{t}"')
async def rewrite(con, mapper: Mapper, cfg: dict, dry: bool) -> None:
pat = prefilter(cfg)
by_table: dict[str, list[tuple[str, str]]] = {}
for t, c, dt in await columns(con):
by_table.setdefault(t, []).append((c, dt))
for table, cols in by_table.items():
for col, dt in cols:
q = f'SELECT ctid, "{col}"::text AS v FROM "{table}" WHERE "{col}"::text ~ $1'
rows = await con.fetch(q, pat)
updates = []
for r in rows:
new = mapper.text(r["v"])
if new != r["v"]:
updates.append((new, r["ctid"]))
if not updates:
continue
print(f" {table}.{col}: {len(updates)} rows")
if dry:
continue
cast = {"jsonb": "::jsonb", "json": "::json", "inet": "::inet", "cidr": "::cidr",
"macaddr": "::macaddr"}.get(dt, "")
if dt == "ARRAY":
udt = await con.fetchval(
"SELECT udt_name FROM information_schema.columns "
"WHERE table_name = $1 AND column_name = $2", table, col)
cast = f"::{udt.lstrip('_')}[]"
await con.executemany(
f'UPDATE "{table}" SET "{col}" = $1{cast} WHERE ctid = $2', updates)
async def reset_users(con, cfg: dict, dry: bool) -> None:
sys.path.insert(0, str(Path(cfg["netork_src"]).expanduser()))
from netork.core.security import hash_password # noqa: E402
admin = cfg.get("admin_from", "chris")
password = cfg.get("admin_password", "netork-demo")
users = await con.fetch("SELECT id, username FROM users ORDER BY username")
print(f" users: {[u['username'] for u in users]}")
if dry:
return
n = 0
for u in users:
if u["username"] == admin:
await con.execute(
"UPDATE users SET username = 'netork', email = $2, hashed_password = $3, "
"totp_secret = NULL, totp_enabled = false, token_version = token_version + 1 "
"WHERE id = $1", u["id"], f"netork@{DEMO_DOMAIN}", hash_password(password))
else:
n += 1
await con.execute(
"UPDATE users SET username = $2, email = $3, hashed_password = $4, "
"totp_secret = NULL, totp_enabled = false, is_active = false WHERE id = $1",
u["id"], f"operator{n}", f"operator{n}@{DEMO_DOMAIN}", hash_password(os.urandom(16).hex()))
role = await con.fetchval("SELECT id FROM roles WHERE lower(name) IN ('administrator', 'admin') LIMIT 1")
if role:
await con.execute("UPDATE users SET role_id = $1, is_superuser = true WHERE username = 'netork'", role)
print(f" admin '{admin}' is now 'netork' / '{password}'")
async def leak_report(con, cfg: dict, originals: list[str]) -> int:
needles = [n for n in originals if len(n) >= 4]
if not needles:
return 0
pat = "|".join(re.escape(n) for n in needles)
found = 0
for t, c, _ in await columns(con):
n = await con.fetchval(f'SELECT count(*) FROM "{t}" WHERE "{c}"::text ~* $1', pat)
if n:
found += n
sample = await con.fetchval(
f'SELECT substring("{c}"::text from $1) FROM "{t}" WHERE "{c}"::text ~* $1 LIMIT 1',
f"(?i)(.{{0,30}}(?:{pat}).{{0,30}})")
print(f" LEAK {t}.{c}: {n} rows, e.g. …{sample}…")
return found
async def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--dsn", default=os.environ.get("DEMO_DSN", DEFAULT_DSN))
ap.add_argument("--map", type=Path, default=DEFAULT_MAP)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--report-only", action="store_true", help="only run the leak report")
args = ap.parse_args()
host = re.search(r"@([^:/]+)", args.dsn)
if not host or host.group(1) not in ("127.0.0.1", "localhost", "::1"):
sys.exit("Refusing: this only runs against a local copy.")
cfg = json.loads(args.map.read_text())
mapper = Mapper(cfg)
originals = [*cfg.get("domains", []), *cfg.get("hostnames", {}), *cfg.get("terms", {}),
*cfg.get("leak_terms", [])]
con = await asyncpg.connect(args.dsn)
try:
if not args.report_only:
async with con.transaction():
print("secrets:")
await scrub_secrets(con, args.dry_run)
print("users:")
await reset_users(con, cfg, args.dry_run)
await collect_public(con, mapper)
print(f"public addresses seen as addresses: {len(mapper.known_public)}")
print("rewriting:")
await rewrite(con, mapper, cfg, args.dry_run)
print("ipv4 /16 mapping:", json.dumps(mapper.prefix16))
print("public addresses mapped:", len(mapper.public))
if mapper.unmapped_public:
top = sorted(mapper.unmapped_public.items(), key=lambda x: -x[1])[:40]
print("left as is (versions? add real ones to public_ips in the map):")
print(" " + ", ".join(f"{ip} ({n}x)" for ip, n in top))
print("leak report:")
n = await leak_report(con, cfg, originals)
if not args.report_only and mapper.unmapped_public:
print(f" review: {len(mapper.unmapped_public)} public-looking dotted quads left as is (listed above)")
print(" clean" if n == 0 else f" {n} rows still match")
finally:
await con.close()
if __name__ == "__main__":
asyncio.run(main())
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Local netOrk demo instance for website screenshots.
#
# up.sh restore <dump> fresh demo DB from a pg_dump -Fc file, then anonymize
# up.sh start API on :8000 and UI on :5173 (foreground, Ctrl-C stops)
# up.sh stop stop the demo database container
#
# Only the API and the UI run: no Celery worker, no beat, no Redis. Nothing
# polls, nothing reboots, nothing reaches a device. Stored credentials are
# emptied by anonymize.py and the encryption key is a fresh random one, so
# even a leftover value could not be decrypted.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
DEMO="${NETORK_DEMO_DIR:-$HOME/.cache/netork-demo}"
SRC="$DEMO/src"
VENV="${NETORK_VENV:-$HOME/dev/NetOrk/.venv}"
VERSION="${NETORK_DEMO_VERSION:-v0.28.0}"
NETORK_REPO="${NETORK_REPO:-$HOME/dev/NetOrk}"
DB=netork-demo-db
PORT=55432
ensure_src() {
if [ ! -d "$SRC/netork" ]; then
mkdir -p "$SRC"
git -C "$NETORK_REPO" archive "$VERSION" | tar -x -C "$SRC"
fi
}
ensure_db() {
if ! docker ps --format '{{.Names}}' | grep -qx "$DB"; then
docker start "$DB" 2>/dev/null || docker run -d --name "$DB" \
-p 127.0.0.1:$PORT:5432 -e POSTGRES_DB=netork -e POSTGRES_USER=netork \
-e POSTGRES_PASSWORD=demo -v netork-demo-pg:/var/lib/postgresql/data postgres:16-alpine
until docker exec "$DB" pg_isready -U netork -q; do sleep 1; done
fi
}
case "${1:-}" in
restore)
dump="${2:?usage: up.sh restore <dump file>}"
ensure_src; ensure_db
docker exec "$DB" psql -U netork -d postgres -q \
-c "DROP DATABASE IF EXISTS netork WITH (FORCE)" -c "CREATE DATABASE netork"
docker exec -i "$DB" pg_restore -U netork -d netork --no-owner --no-privileges < "$dump" \
|| echo "pg_restore reported errors (often only missing roles/extensions); checking ..."
got=$(docker exec "$DB" psql -U netork -tA -c "SELECT version_num FROM alembic_version")
want=$(cd "$SRC" && PATH="$VENV/bin:$PATH" alembic heads 2>/dev/null | awk '{print $1}')
echo "dump schema: $got $VERSION head: $want"
[ "$got" = "$want" ] || echo "WARNING: schema differs from $VERSION; screens may not match the release."
"$VENV/bin/python" "$HERE/anonymize.py"
;;
start)
ensure_src; ensure_db
[ -d "$SRC/ui/node_modules" ] || (cd "$SRC/ui" && npm ci --no-audit --no-fund)
export DATABASE_URL="postgresql+asyncpg://netork:demo@127.0.0.1:$PORT/netork"
export ENVIRONMENT=development
export SECRET_KEY="$(openssl rand -hex 32)"
export CREDENTIAL_ENCRYPTION_KEY="$("$VENV/bin/python" -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')"
# Nothing listens on port 1: no task can be queued, so no worker could act.
export REDIS_URL=redis://127.0.0.1:1/0 CELERY_BROKER_URL=redis://127.0.0.1:1/0 CELERY_RESULT_BACKEND=redis://127.0.0.1:1/1
cd "$SRC"
"$VENV/bin/uvicorn" netork.api.main:app --host 127.0.0.1 --port 8000 &
api=$!
trap 'kill $api 2>/dev/null' EXIT
cd ui && npx vite --host 127.0.0.1 --port 5173 --strictPort
;;
stop)
docker stop "$DB"
;;
*)
sed -n '2,12p' "$0"; exit 1 ;;
esac