479 lines
16 KiB
Python
479 lines
16 KiB
Python
#!/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()
|