Files
ADHDbot/scripts/generate_intranet.py
2025-11-11 23:11:59 -06:00

479 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Regenerate intranet/index.html by polling each server over SSH."""
from __future__ import annotations
import base64
import json
import pathlib
import shlex
import subprocess
import textwrap
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
OUTPUT_PATH = REPO_ROOT / "intranet" / "index.html"
@dataclass
class HostConfig:
slug: str
ssh: str
title: str
subtitle: str
tags: List[str]
vhost_notes: Dict[str, str]
docker_notes: Dict[str, str]
HOSTS: List[HostConfig] = [
HostConfig(
slug="la",
ssh="root@la.chelseawoodruff.net",
title="Los Angeles",
subtitle="la.chelseawoodruff.net",
tags=["nginx", "FRP relay"],
vhost_notes={
"chat.scorpi.us": "Public chat frontend with HTTPS redirect",
},
docker_notes={
"frps": "Snowdreamtech FRP reverse proxy",
},
),
HostConfig(
slug="virginia",
ssh="root@virginia.chelseawoodruff.net",
title="Virginia",
subtitle="virginia.chelseawoodruff.net",
tags=["nginx", "Git", "Vikunja", "Dokuwiki", "Custom apps"],
vhost_notes={
"hightimesfrom.space": "Personal landing page",
"pm.scorpi.us": "Leantime / PM suite",
"wiki.scorpi.us": "Dokuwiki knowledge base",
"news.scorpi.us": "Pseudo news clone",
"git.scorpi.us": "Gitea instance",
"reddit.scorpi.us": "Reddit proxy (forced HTTPS)",
"blocked.scorpi.us": "Block page mirror",
"requests.scorpi.us": "Requesty helper",
"youtube.scorpi.us": "PseudoTube experiment",
},
docker_notes={
"claude-proxy": "Claude proxy forwarder",
"solar_420": "Solartime demo app",
"frps": "Snowdreamtech FRP relay",
"balanceboard_app": "Balanceboard UI",
"balanceboard_postgres": "Balanceboard DB",
"vikunja_vikunja_1": "Vikunja task manager",
"vikunja_db_1": "Vikunja Postgres",
"gitea": "Gitea service",
"gitea_db_1": "Gitea Postgres",
"dokuwiki": "Dokuwiki container",
"requesty": "Requesty API",
"psuedo-tube": "PseudoTube UI",
"block-page": "Block-page helper",
},
),
HostConfig(
slug="chicago",
ssh="root@chicago.scorpi.us",
title="Chicago",
subtitle="chicago.scorpi.us",
tags=["nginx", "ADHDbot", "ntfy", "IRC"],
vhost_notes={
"adhd.scorpi.us": "ADHDbot UI/API",
"matrix.scorpi.us": "Matrix homeserver proxy",
"ntfy.scorpi.us": "ntfy notification hub",
},
docker_notes={
"adhdbot-app_adhdbot_1": "ADHDbot FastAPI stack",
"ntfy": "ntfy topic server",
"inspircd": "InspIRCd daemon",
},
),
HostConfig(
slug="dallas",
ssh="root@dallas.scorpi.us",
title="Dallas",
subtitle="dallas.scorpi.us",
tags=["nginx", "Automation"],
vhost_notes={
"n8n.dallas.scorpi.us": "n8n low-code automation suite",
},
docker_notes={
"n8n_n8n_1": "n8n worker (localhost only)",
},
),
HostConfig(
slug="phoenix",
ssh="root@phoenix.scorpi.us",
title="Phoenix",
subtitle="phoenix.scorpi.us",
tags=["Discourse", "Discord bridge"],
vhost_notes={},
docker_notes={
"app": "Discourse stack",
"discord-discourse-bridge": "Discord ↔ Discourse bridge",
},
),
]
REMOTE_PY = textwrap.dedent(
"""
import glob
import json
import os
import subprocess
def collect_nginx():
base = "/etc/nginx/sites-enabled"
sites = []
if not os.path.isdir(base):
return sites
for path in sorted(glob.glob(os.path.join(base, "*"))):
if not os.path.isfile(path):
continue
try:
with open(path, "r", encoding="utf-8") as handle:
lines = handle.readlines()
except OSError:
continue
server_names = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.startswith("server_name"):
content = stripped.split(None, 1)[1] if " " in stripped else ""
content = content.split("#", 1)[0].strip()
if content.endswith(";"):
content = content[:-1].strip()
if content:
server_names.extend([token for token in content.split() if token])
sites.append({"filename": os.path.basename(path), "server_names": server_names})
return sites
def collect_docker():
try:
output = subprocess.check_output(
["docker", "ps", "--format", "{{json .}}"], text=True, timeout=10
)
except Exception:
return []
containers = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
containers.append(
{
"name": data.get("Names"),
"image": data.get("Image"),
"ports": data.get("Ports"),
}
)
return containers
payload = {
"hostname": os.uname().nodename,
"nginx": collect_nginx(),
"docker": collect_docker(),
}
print("{{JSON}}" + json.dumps(payload) + "{{/JSON}}")
"""
).strip()
def run_remote_script(target: str) -> Dict[str, Any]:
encoded = base64.b64encode(REMOTE_PY.encode("utf-8")).decode("ascii")
remote_cmd = (
"python3 -c "
+ shlex.quote(
"import base64, json; exec(base64.b64decode({}))".format(repr(encoded))
)
)
ssh_cmd = [
"ssh",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=no",
target,
remote_cmd,
]
completed = subprocess.run(
ssh_cmd,
capture_output=True,
text=True,
check=False,
)
if completed.returncode != 0:
raise RuntimeError(completed.stderr.strip() or completed.stdout.strip())
marker_start = "{{JSON}}"
marker_end = "{{/JSON}}"
stdout = completed.stdout
start = stdout.find(marker_start)
end = stdout.find(marker_end)
if start == -1 or end == -1:
raise RuntimeError("Could not locate JSON payload in ssh output")
json_text = stdout[start + len(marker_start) : end]
return json.loads(json_text)
def format_ports(ports: Optional[str]) -> str:
ports = (ports or "").strip()
return ports if ports else ""
def render_host_card(config: HostConfig, payload: Optional[Dict[str, Any]], error: Optional[str]) -> str:
tag_html = "".join(f'<span class="tag">{tag}</span>' for tag in config.tags)
if error:
body = f'<p class="error">Unable to load data: {error}</p>'
nginx_html = ""
docker_html = ""
else:
nginx_entries = payload.get("nginx", []) if payload else []
docker_entries = payload.get("docker", []) if payload else []
if nginx_entries:
items = []
for entry in nginx_entries:
names = entry.get("server_names") or [entry.get("filename", "unknown")]
label = ", ".join(names)
note = config.vhost_notes.get(names[0]) or config.vhost_notes.get(entry.get("filename", ""))
if note:
items.append(f"<li><strong>{label}</strong> {note}</li>")
else:
items.append(f"<li>{label}</li>")
nginx_html = "<div><p class=\"section-title\">nginx sites</p><ul>" + "".join(items) + "</ul></div>"
else:
nginx_html = "<div><p class=\"section-title\">nginx sites</p><p class=\"muted\">No vhosts detected.</p></div>"
if docker_entries:
rows = []
for entry in docker_entries:
name = entry.get("name") or "unknown"
image = entry.get("image") or "?"
ports = format_ports(entry.get("ports"))
note = config.docker_notes.get(name)
note_span = f'<br /><span class="muted tiny">{note}</span>' if note else ""
rows.append(
"<tr>"
f"<td>{name}{note_span}</td>"
f"<td>{image}</td>"
f"<td>{ports}</td>"
"</tr>"
)
docker_html = (
"<div><p class=\"section-title\">Docker</p>"
"<table class=\"docker-table\"><thead><tr><th>Name</th><th>Image</th><th>Ports</th></tr></thead>"
"<tbody>"
+ "".join(rows)
+ "</tbody></table></div>"
)
else:
docker_html = "<div><p class=\"section-title\">Docker</p><p class=\"muted\">No containers running.</p></div>"
body = nginx_html + docker_html
return textwrap.dedent(
f"""
<article class="card" id="{config.slug}">
<div>
<p class="eyebrow">{config.title}</p>
<h2>{config.subtitle}</h2>
<div class="tag-list">{tag_html}</div>
</div>
{body}
</article>
"""
).strip()
def render_html(reports: List[str]) -> str:
cards_html = "\n".join(reports)
return textwrap.dedent(
f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Service Inventory</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<style>
:root {{
color: #0f172a;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
}}
body {{
margin: 0;
background: radial-gradient(circle at top left, rgba(56, 189, 248, 0.15), transparent 45%),
radial-gradient(circle at bottom right, rgba(192, 132, 252, 0.2), transparent 40%), #e2e8f0;
min-height: 100vh;
padding: clamp(1.5rem, 3vw, 3rem);
}}
.shell {{
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}}
header {{
background: rgba(255, 255, 255, 0.9);
border-radius: 1.5rem;
padding: clamp(1.5rem, 3vw, 2.25rem);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.15);
border: 1px solid rgba(15, 23, 42, 0.05);
}}
header h1 {{
margin: 0 0 0.25rem;
font-size: clamp(2rem, 4vw, 2.75rem);
color: #0f172a;
}}
header p {{
margin: 0;
color: #475569;
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
}}
.card {{
background: rgba(255, 255, 255, 0.92);
border-radius: 1.25rem;
padding: 1.5rem;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.1);
border: 1px solid rgba(15, 23, 42, 0.05);
display: flex;
flex-direction: column;
gap: 1rem;
}}
.card h2 {{
margin: 0;
font-size: 1.25rem;
color: #0f172a;
}}
.eyebrow {{
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.72rem;
color: #64748b;
margin-bottom: 0.35rem;
}}
.section-title {{
font-weight: 600;
color: #475569;
margin-bottom: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.78rem;
}}
ul {{
margin: 0;
padding-left: 1.25rem;
color: #0f172a;
line-height: 1.45;
}}
ul li + li {{
margin-top: 0.35rem;
}}
.tag-list {{
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}}
.tag {{
border-radius: 999px;
padding: 0.25rem 0.85rem;
font-size: 0.82rem;
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.25);
color: #1d4ed8;
}}
.docker-table {{
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}}
.docker-table th {{
text-align: left;
padding: 0.4rem 0;
color: #64748b;
font-weight: 600;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}}
.docker-table td {{
padding: 0.35rem 0;
border-bottom: 1px solid rgba(15, 23, 42, 0.03);
}}
.muted {{
color: #94a3b8;
}}
.muted.tiny {{
font-size: 0.8rem;
}}
.error {{
color: #b91c1c;
background: rgba(248, 113, 113, 0.15);
padding: 0.75rem;
border-radius: 0.75rem;
border: 1px solid rgba(248, 113, 113, 0.25);
}}
@media (max-width: 640px) {{
body {{
padding: 1rem;
}}
.card {{
padding: 1.1rem;
}}
}}
</style>
</head>
<body>
<div class="shell">
<header>
<p class="eyebrow">Internal inventory</p>
<h1>Intranet Services</h1>
<p>Live snapshot of nginx vhosts and Docker workloads across Chelsea's network.</p>
</header>
<section class="grid">
{cards_html}
</section>
</div>
</body>
</html>
"""
).strip()
def main() -> None:
reports: List[str] = []
for host in HOSTS:
try:
payload = run_remote_script(host.ssh)
reports.append(render_host_card(host, payload, error=None))
except Exception as exc: # pragma: no cover - network dependent
reports.append(render_host_card(host, payload=None, error=str(exc)))
html = render_html(reports)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(html, encoding="utf-8")
print(f"Wrote {OUTPUT_PATH}")
if __name__ == "__main__":
main()