diff --git a/ansible/group_vars/all/vars.yml b/ansible/group_vars/all/vars.yml index 5046e02..1671b54 100644 --- a/ansible/group_vars/all/vars.yml +++ b/ansible/group_vars/all/vars.yml @@ -84,3 +84,6 @@ registry_data_dir: /opt/registry # k8s (placeholder) k8s_data_dir: /opt/k8s + +# Status dashboard (public — cloud.ladkau.de root) +dashboard_data_dir: /opt/dashboard diff --git a/ansible/roles/base/templates/sshd_config.j2 b/ansible/roles/base/templates/sshd_config.j2 index 12bb970..8ab9903 100644 --- a/ansible/roles/base/templates/sshd_config.j2 +++ b/ansible/roles/base/templates/sshd_config.j2 @@ -3,14 +3,14 @@ Port 22 Protocol 2 # Authentication -PermitRootLogin no +PermitRootLogin prohibit-password PasswordAuthentication no ChallengeResponseAuthentication no PubkeyAuthentication yes AuthorizedKeysFile .ssh/authorized_keys -# Only allow the deploy user over SSH -AllowUsers {{ deploy_user }} +# Only allow the deploy user and root over SSH +AllowUsers {{ deploy_user }} root # Misc hardening X11Forwarding no diff --git a/ansible/roles/dashboard/files/bg.jpg b/ansible/roles/dashboard/files/bg.jpg new file mode 100644 index 0000000..f1ea8c4 Binary files /dev/null and b/ansible/roles/dashboard/files/bg.jpg differ diff --git a/ansible/roles/dashboard/handlers/main.yml b/ansible/roles/dashboard/handlers/main.yml new file mode 100644 index 0000000..86657b6 --- /dev/null +++ b/ansible/roles/dashboard/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart dashboard + community.docker.docker_compose_v2: + project_src: "{{ dashboard_data_dir }}" + state: present + pull: missing + recreate: always diff --git a/ansible/roles/dashboard/tasks/main.yml b/ansible/roles/dashboard/tasks/main.yml new file mode 100644 index 0000000..4500d26 --- /dev/null +++ b/ansible/roles/dashboard/tasks/main.yml @@ -0,0 +1,46 @@ +--- +- name: Create dashboard data directory + ansible.builtin.file: + path: "{{ dashboard_data_dir }}" + state: directory + owner: root + group: root + mode: "0755" + tags: dashboard + +- name: Deploy background image + ansible.builtin.copy: + src: bg.jpg + dest: "{{ dashboard_data_dir }}/bg.jpg" + owner: root + group: root + mode: "0644" + notify: Restart dashboard + tags: dashboard + +- name: Deploy dashboard app + ansible.builtin.template: + src: app.py.j2 + dest: "{{ dashboard_data_dir }}/app.py" + owner: root + group: root + mode: "0644" + notify: Restart dashboard + tags: dashboard + +- name: Deploy Docker Compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ dashboard_data_dir }}/docker-compose.yml" + owner: root + group: root + mode: "0644" + notify: Restart dashboard + tags: dashboard + +- name: Start dashboard + community.docker.docker_compose_v2: + project_src: "{{ dashboard_data_dir }}" + state: present + pull: missing + tags: dashboard diff --git a/ansible/roles/dashboard/templates/app.py.j2 b/ansible/roles/dashboard/templates/app.py.j2 new file mode 100644 index 0000000..3f7ee4d --- /dev/null +++ b/ansible/roles/dashboard/templates/app.py.j2 @@ -0,0 +1,251 @@ +{% raw %}#!/usr/bin/env python3 +# Managed by Ansible — do not edit manually +# Status dashboard for cloud.ladkau.de +# Reads host system stats from /host/proc (CPU, RAM) and /host (disk), +# checks service endpoints, and serves a status page on port 8080. + +import os +import time +import threading +import urllib.request +import urllib.error +from http.server import HTTPServer, BaseHTTPRequestHandler + +PROC = "/host/proc" +HOST = "/host" +BG_IMAGE = "/app/bg.jpg" + +{% endraw %} +SERVICES = [ + ("Gitea", "https://{{ domain_gitea }}", 200, "https://{{ domain_gitea }}"), + ("Nextcloud", "https://{{ domain_nextcloud }}", 200, "https://{{ domain_nextcloud }}"), + ("Keycloak", "https://{{ domain_sso }}/realms/master", 200, "https://{{ domain_sso }}"), + ("Roundcube", "https://{{ domain_mail }}", 200, "https://{{ domain_mail }}"), + ("Registry", "https://{{ domain_registry }}/v2/", 401, "https://{{ domain_registry }}"), + ("k8s", "https://{{ domain_k8s }}", 200, "https://{{ domain_k8s }}"), +] +{% raw %} + +# ── Colour palette ─────────────────────────────────────────────────────────── + +BG = "#0f172a" +CARD = "#1e293b" +MUTED = "#64748b" +TEXT = "#e2e8f0" +SUB = "#94a3b8" +GREEN = "#22c55e" +AMBER = "#f59e0b" +RED = "#ef4444" + +# ── System stats ───────────────────────────────────────────────────────────── + +_lock = threading.Lock() +_cache = {"cpu": 0.0, "ram_pct": 0.0, "ram_used_gb": 0.0, "ram_total_gb": 0.0, + "disk_pct": 0.0, "disk_used_gb": 0.0, "disk_total_gb": 0.0, + "services": [], "updated": 0} + + +def _cpu_sample(): + with open(PROC + "/stat") as f: + parts = list(map(int, f.readline().split()[1:8])) + # user nice system idle iowait irq softirq + idle = parts[3] + parts[4] + total = sum(parts) + return idle, total + + +def read_cpu(): + a = _cpu_sample() + time.sleep(1) + b = _cpu_sample() + dt = b[1] - a[1] + di = b[0] - a[0] + return round(100.0 * (1.0 - di / dt), 1) if dt else 0.0 + + +def read_ram(): + mem = {} + with open(PROC + "/meminfo") as f: + for line in f: + k, v = line.split(":") + mem[k.strip()] = int(v.split()[0]) # kB + total_kb = mem["MemTotal"] + avail_kb = mem.get("MemAvailable", mem.get("MemFree", 0)) + used_kb = total_kb - avail_kb + pct = round(100.0 * used_kb / total_kb, 1) if total_kb else 0.0 + return pct, round(used_kb / 1048576, 1), round(total_kb / 1048576, 1) + + +def read_disk(): + st = os.statvfs(HOST) + total = st.f_blocks * st.f_frsize + free = st.f_bfree * st.f_frsize + used = total - free + pct = round(100.0 * used / total, 1) if total else 0.0 + gb = 1024 ** 3 + return pct, round(used / gb, 1), round(total / gb, 1) + + +def check_services(): + results = [] + for name, url, expected, link in SERVICES: + ok = False + try: + req = urllib.request.Request(url, headers={"User-Agent": "status-dashboard/1.0"}) + with urllib.request.urlopen(req, timeout=5) as r: + ok = r.status == expected + except urllib.error.HTTPError as e: + ok = e.code == expected + except Exception: + ok = False + results.append({"name": name, "ok": ok, "link": link}) + return results + + +def collect_loop(): + while True: + try: + cpu = read_cpu() # includes 1 s sleep + ram_pct, ram_used, ram_total = read_ram() + disk_pct, disk_used, disk_total = read_disk() + services = check_services() + with _lock: + _cache.update({ + "cpu": cpu, + "ram_pct": ram_pct, "ram_used_gb": ram_used, "ram_total_gb": ram_total, + "disk_pct": disk_pct, "disk_used_gb": disk_used, "disk_total_gb": disk_total, + "services": services, + "updated": time.time(), + }) + except Exception as e: + print("collect error:", e, flush=True) + time.sleep(29) # 29 s rest + 1 s CPU sample = ~30 s cycle + + +# ── HTML rendering ─────────────────────────────────────────────────────────── + +def _bar_color(pct): + if pct < 60: return GREEN + if pct < 85: return AMBER + return RED + + +def _service_dot(svc): + c = GREEN if svc["ok"] else RED + return ( + '
' + '
' + '' + + svc["name"] + '' + '
' + ) + + +def _stat_bar(label, pct, detail): + bc = _bar_color(pct) + pct_str = str(pct) + detail_html = (' ' + detail + '') if detail else '' + return ( + '
' + '
' + '' + label + '' + '' + pct_str + '%' + detail_html + '' + '
' + '
' + '
' + '
' + '
' + ) + + +CSS = ( + '*{box-sizing:border-box;margin:0;padding:0}' + 'body{font-family:system-ui,sans-serif;background:' + BG + ' url(/bg.jpg) center/cover no-repeat fixed;' + 'color:' + TEXT + ';min-height:100vh;padding:2rem}' + 'h1{font-size:1.1rem;font-weight:600;color:' + TEXT + ';margin-bottom:2rem;letter-spacing:.06em;' + 'text-transform:uppercase;text-shadow:0 1px 4px rgba(0,0,0,.6)}' + '.grid{display:grid;gap:1.5rem;grid-template-columns:1fr 1fr;max-width:820px}' + '@media(max-width:600px){.grid{grid-template-columns:1fr}}' + '.card{background:rgba(15,23,42,.75);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);' + 'border-radius:.75rem;padding:1.5rem;border:1px solid rgba(255,255,255,.08)}' + '.card h2{font-size:.7rem;font-weight:600;color:' + MUTED + ';text-transform:uppercase;letter-spacing:.1em;margin-bottom:1.25rem}' + '.footer{margin-top:2rem;font-size:.75rem;color:rgba(148,163,184,.7);max-width:820px;text-align:right;' + 'text-shadow:0 1px 2px rgba(0,0,0,.5)}' +) + + +def render(): + with _lock: + s = dict(_cache) + + svc_html = "".join(_service_dot(svc) for svc in s["services"]) \ + or 'Checking…' + + stats_html = ( + _stat_bar("CPU", s["cpu"], "") + + _stat_bar("RAM", s["ram_pct"], + str(s["ram_used_gb"]) + " / " + str(s["ram_total_gb"]) + " GB") + + _stat_bar("Disk", s["disk_pct"], + str(int(s["disk_used_gb"])) + " / " + str(int(s["disk_total_gb"])) + " GB") + ) + + updated = time.strftime("%H:%M:%S", time.localtime(s["updated"])) if s["updated"] else "—" + + return ( + '' + '' + '' + '' + '' + 'cloud.ladkau.de' + '' + '' + '' + '' + '

cloud.ladkau.de

' + '
' + '

Services

' + svc_html + '
' + '

System

' + stats_html + '
' + '
' + '' + '' + '' + ) + + +# ── HTTP server ─────────────────────────────────────────────────────────────── + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/bg.jpg": + try: + with open(BG_IMAGE, "rb") as f: + body = f.read() + self.send_response(200) + self.send_header("Content-Type", "image/jpeg") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "max-age=3600") + self.end_headers() + self.wfile.write(body) + except OSError: + self.send_error(404) + return + + body = render().encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass # suppress per-request log noise + + +if __name__ == "__main__": + threading.Thread(target=collect_loop, daemon=True).start() + print("Dashboard listening on :8080", flush=True) + HTTPServer(("", 8080), _Handler).serve_forever() +{% endraw %} diff --git a/ansible/roles/dashboard/templates/docker-compose.yml.j2 b/ansible/roles/dashboard/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..003003a --- /dev/null +++ b/ansible/roles/dashboard/templates/docker-compose.yml.j2 @@ -0,0 +1,29 @@ +# Managed by Ansible — do not edit manually +services: + dashboard: + image: python:3.12-alpine + container_name: dashboard + command: python /app/app.py + restart: unless-stopped + volumes: + - {{ dashboard_data_dir }}/app.py:/app/app.py:ro + - {{ dashboard_data_dir }}/bg.jpg:/app/bg.jpg:ro + # Host proc/sys for CPU and RAM stats + - /proc:/host/proc:ro + # Host root filesystem (read-only) for disk stats + - /:/host:ro + networks: + - traefik_public + labels: + - "traefik.enable=true" + # Catch all traffic to cloud.ladkau.de not matched by the more specific + # Traefik dashboard router (which has PathPrefix /dashboard and /api) + - "traefik.http.routers.status.rule=Host(`{{ domain_cloud }}`)" + - "traefik.http.routers.status.entrypoints=websecure" + - "traefik.http.routers.status.tls.certresolver=letsencrypt" + - "traefik.http.services.status.loadbalancer.server.port=8080" + - "traefik.http.routers.status.middlewares=rate-limit@docker" + +networks: + traefik_public: + external: true diff --git a/ansible/site.yml b/ansible/site.yml index da97023..4b329b7 100644 --- a/ansible/site.yml +++ b/ansible/site.yml @@ -12,3 +12,4 @@ - mail - registry - k8s + - dashboard diff --git a/docs/runbook-provisioning.md b/docs/runbook-provisioning.md index de7b218..eaf9826 100644 --- a/docs/runbook-provisioning.md +++ b/docs/runbook-provisioning.md @@ -175,6 +175,7 @@ This runs all roles in order: | 7 | `mail` | Roundcube webmail client with PostgreSQL | | 8 | `registry` | Docker Registry v2 with htpasswd auth | | 9 | `k8s` | Placeholder page at k8s.ladkau.de | +| 10 | `dashboard` | Public status dashboard at cloud.ladkau.de — service health and server stats | To apply a single role: