vanityontour-status/scripts/check_status.py
Oliver G c29f0953db
docs: IPv6-Störung auf hetzner ist behoben, Kommentar richtigstellen
Die "tcp established"-Regel der Hetzner-Robot-Firewall wurde von IPv4 auf
IPv4 & IPv6 erweitert; ausgehendes IPv6-TCP funktioniert wieder, per tcpdump
verifiziert. Der AF_INET-Filter bleibt bewusst bestehen — er hält die
gemessenen Antwortzeiten deterministisch und sichert den Lauf gegen eine
Wiederholung ab. Der Kommentar behauptete bisher, der Filter sei ein
Provisorium bis zur Reparatur.
2026-08-03 07:54:55 +02:00

218 lines
11 KiB
Python

#!/usr/bin/env python3
"""
VanityOnTour Status Checker
Runs via GitHub Actions every 5 minutes, writes public/status.json
"""
import json
import ssl
import socket
import time
import urllib.request
import urllib.error
from datetime import datetime, timezone
# Bewusst nur IPv4. Am 03.08.2026 war ausgehendes IPv6-TCP auf hetzner tot (die
# "tcp established"-Regel der stateless Robot-Firewall galt nur für IPv4), und
# jede Domain mit AAAA-Record kostete ~20s Timeout, bevor Python auf IPv4
# zurückfiel — Antwortzeiten von 20s statt 150ms, Lauf länger als das
# Cron-Intervall. Die Firewall-Regel ist inzwischen auf IPv4 & IPv6 erweitert und
# IPv6 funktioniert wieder; der Filter bleibt trotzdem, weil er die Messung
# deterministisch hält und den Lauf gegen eine Wiederholung absichert.
_getaddrinfo_orig = socket.getaddrinfo
def _getaddrinfo_ipv4_only(*args, **kwargs):
return [ai for ai in _getaddrinfo_orig(*args, **kwargs) if ai[0] == socket.AF_INET]
socket.getaddrinfo = _getaddrinfo_ipv4_only
OUTPUT_FILE = "public/status.json"
# Seit der VPN-Absicherung (Phase 2) antworten die Admin-Oberflächen auf ihren
# öffentlichen Domains nur noch mit 403. Der Checker läuft per Cron auf hetzner,
# das selbst WireGuard-Peer 10.10.0.14 ist — diese Dienste werden deshalb über
# "check_url" intern geprüft. "url" bleibt die öffentliche Adresse: die Statusseite
# zeigt daraus nur den Hostnamen an, interne Adressen gehören nicht ins status.json.
WEBSITES = [
{"name": "VanityOnTour", "url": "https://vanityontour.de", "group": "websites", "expect": [200, 301, 302]},
{"name": "News Portal", "url": "https://news.vanityontour.de", "group": "websites", "expect": [200, 301, 302]},
{"name": "Wiki", "url": "https://wiki.vanityontour.de", "group": "websites", "expect": [200, 301, 302]},
{"name": "StaySense", "url": "https://staysense.vanityontour.de", "group": "websites", "expect": [200, 301, 302]},
{"name": "StaySense Landing", "url": "https://landing.staysense.vanityontour.de", "group": "websites", "expect": [200, 301, 302]},
{"name": "VanityCast Landing", "url": "https://vanitycast.vanityontour.de", "group": "websites", "expect": [200, 301, 302]},
{"name": "Kurzlinks", "url": "https://go.vanityontour.de", "group": "websites", "expect": [200, 301, 302]},
{"name": "N8N Automation", "url": "https://n8n.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "http://10.10.0.13:5678"},
{"name": "Nginx Proxy Manager", "url": "https://nginx.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "http://10.10.0.13:81"},
{"name": "Nginx Proxy Mgr (VoT)", "url": "https://ng.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "http://10.10.0.12:81"},
# Root liefert öffentlich zwar 302, aber /dashboard ist gesperrt — die echte
# öffentliche Status-Page beweist dagegen, dass Kuma wirklich antwortet.
{"name": "Uptime Kuma", "url": "https://server.vanityontour.de", "group": "tools", "expect": [200], "check_url": "https://server.vanityontour.de/status/vanity"},
{"name": "Statistiken", "url": "https://stats.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "http://127.0.0.1:3000"},
{"name": "App Backend", "url": "https://app.vanityontour.de", "group": "tools", "expect": [200, 301, 302]},
# Grafana und CloudPanel laufen auf hetzner selbst — localhost statt 10.10.0.14,
# damit die Prüfung auch bei liegendem Tunnel noch stimmt.
{"name": "CloudPanel", "url": "https://cp.blog.vanityontour.de", "group": "tools", "expect": [200, 301, 302], "check_url": "https://127.0.0.1:8443"},
# Dienste auf dem Hostinger VPS (Traefik). Forgejo und Postiz haben bewusst
# noch öffentliche Logins und werden deshalb ganz normal von außen geprüft.
{"name": "Forgejo", "url": "https://git.giertz.biz", "group": "tools", "expect": [200, 301, 302]},
{"name": "Postiz", "url": "https://social.vanityontour.de", "group": "tools", "expect": [200, 301, 302, 307]},
# Achtung, schwaches Signal: Traefik beantwortet die BasicAuth vor dem Proxy.
# Die 401 belegt also nur, dass Traefik läuft und der Auth-Schutz noch greift —
# nicht, dass der Container dahinter lebt. Dass Shlink selbst antwortet, zeigt
# der Health-Endpunkt weiter unten. Eine 200 hier hiesse: Schutz ist weg.
{"name": "Shlink Admin", "url": "https://shlink.vanityontour.de", "group": "tools", "expect": [401]},
{"name": "RSS News API", "url": "https://news.vanityontour.de/health", "group": "apis", "expect": [200]},
{"name": "StaySense API", "url": "https://staysense.vanityontour.de/api/health", "group": "apis", "expect": [200]},
{"name": "Shlink API", "url": "https://go.vanityontour.de/rest/health", "group": "apis", "expect": [200]},
]
SSL_DOMAINS = [
"vanityontour.de",
"news.vanityontour.de",
"wiki.vanityontour.de",
"n8n.vanityontour.de",
"staysense.vanityontour.de",
"server.vanityontour.de",
"vanitycast.vanityontour.de",
"go.vanityontour.de",
"git.giertz.biz",
]
APP_STORE_COUNTRY = "de"
APP_STORE_IDS = [
"6742772476", # Vanity Expense Logbook
"6781152682", # VanityCast
]
def check_http(url: str, expected: list[int]) -> dict:
start = time.time()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
req = urllib.request.Request(
url,
headers={"User-Agent": "VoT-StatusChecker/1.0"},
)
handler = urllib.request.HTTPSHandler(context=ctx)
opener = urllib.request.build_opener(handler)
opener.addheaders = [("User-Agent", "VoT-StatusChecker/1.0")]
with opener.open(req, timeout=10) as resp:
code = resp.status
except urllib.error.HTTPError as e:
code = e.code
except Exception as e:
return {"status": "down", "status_code": None, "response_time_ms": None, "error": str(e)[:80]}
ms = round((time.time() - start) * 1000)
up = code in expected
# 4xx/5xx server errors count as down, not just degraded
status = "up" if up else ("down" if code >= 400 else "degraded")
return {"status": status, "status_code": code, "response_time_ms": ms, "error": None}
def check_ssl(domain: str) -> dict:
try:
ctx = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=15) as sock:
with ctx.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
expires_str = cert.get("notAfter", "")
expires_dt = datetime.strptime(expires_str, "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
days = (expires_dt - datetime.now(timezone.utc)).days
return {"valid": True, "expires_in_days": days, "expires_at": expires_dt.strftime("%Y-%m-%d")}
except Exception as e:
return {"valid": False, "expires_in_days": None, "expires_at": None, "error": str(e)[:60]}
def fetch_app_store(app_id: str) -> dict:
url = f"https://itunes.apple.com/lookup?id={app_id}&country={APP_STORE_COUNTRY}"
try:
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not data.get("results"):
return {"id": app_id, "error": "No results"}
r = data["results"][0]
release_raw = r.get("currentVersionReleaseDate", "")
release_fmt = release_raw[:10] if release_raw else None
return {
"id": app_id,
"name": r.get("trackName"),
"version": r.get("version"),
"rating": r.get("averageUserRating"),
"rating_count": r.get("userRatingCount"),
"rating_current_version": r.get("averageUserRatingForCurrentVersion"),
"rating_count_current_version": r.get("userRatingCountForCurrentVersion"),
"price": r.get("formattedPrice"),
"category": r.get("primaryGenreName"),
"last_update": release_fmt,
"min_ios": r.get("minimumOsVersion"),
"store_url": r.get("trackViewUrl", "").split("?")[0],
"icon_url": r.get("artworkUrl100", "").replace("100x100bb", "200x200bb"),
"seller": r.get("sellerName"),
"error": None,
}
except Exception as e:
return {"id": app_id, "error": str(e)[:80]}
def main():
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
print(f"[{now}] Checking {len(WEBSITES)} services...")
results = []
for site in WEBSITES:
target = site.get("check_url", site["url"])
r = check_http(target, site["expect"])
# check_url beschreibt die interne Netztopologie und wird nicht veröffentlicht
public = {k: v for k, v in site.items() if k != "check_url"}
results.append({**public, **r})
sym = "" if r["status"] == "up" else ""
via = " via " + target if "check_url" in site else ""
print(f" {sym} {site['name']:30s} {r['status']:8s} {r.get('status_code') or '---'} {r.get('response_time_ms') or '---'}ms{via}")
print("Checking SSL certificates...")
ssl_results = {}
for domain in SSL_DOMAINS:
ssl_results[domain] = check_ssl(domain)
d = ssl_results[domain]
print(f" {domain}: {d.get('expires_in_days', '?')} days")
print("Fetching App Store data...")
apps = [fetch_app_store(app_id) for app_id in APP_STORE_IDS]
for a in apps:
if a.get("error"):
print(f" {a.get('id')}: FEHLER {a['error']}")
else:
print(f" {a.get('name')} v{a.get('version', '?')}{a.get('rating') or ''}")
# Overall status
downs = [r for r in results if r["status"] == "down"]
degraded = [r for r in results if r["status"] == "degraded"]
if downs:
overall = "degraded" if len(downs) <= 2 else "down"
elif degraded:
overall = "degraded"
else:
overall = "up"
output = {
"generated_at": now,
"overall": overall,
"services": results,
"ssl": ssl_results,
# "app" bleibt als Einzelfeld erhalten, weil index.html nur manuell auf
# Hostinger aktualisiert wird — eine ältere Seite würde sonst leer laufen.
"app": apps[0] if apps else {"error": "keine App konfiguriert"},
"apps": apps,
}
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"Written to {OUTPUT_FILE} — overall: {overall}")
if __name__ == "__main__":
main()