chore: initial import
This commit is contained in:
478
scripts/generate_intranet.py
Normal file
478
scripts/generate_intranet.py
Normal file
@@ -0,0 +1,478 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user