"""Version-/opdaterings-tjek for homelab-tjenester. Udvidbart register: - 'kind' = hvor DIN kørende version hentes (authentik/gitea/matomo/assets/None). - 'repo' = GitHub owner/repo → 'nyeste' release. None = ingen kilde. Resultater caches 1 time (versioner ændrer sig sjældent; GitHub-rate-limit 60/t).""" import asyncio import json import re import httpx import cache from config import (API_TOKEN, AUTHENTIK, ASSETS_VERSION_URL, MATOMO_BASE, MATOMO_TOKEN, DATA, GITHUB_TOKEN, NTFY_URL, NTFY_TOPIC, NTFY_TOKEN) # Kørende versioner for LXC-tjenester portalen ikke selv kan nå — indsamlet på # Proxmox-værten af portal-versions.py (cron) og pushet hertil. Nøgler = SERVICES-navne. LOCAL_VERSIONS = DATA / "versions_local.json" # Sidst-kendte-gode "nyeste"-tags pr. kilde, persisteret til disk. Et fetch-fejl # (fx GitHub rate-limit 60/t eller en genstart) blanker dermed ALDRIG kolonnen — # værdien opdateres kun ved et vellykket nyt opslag. LATEST_STORE = DATA / "latest_versions.json" def _load_local(): try: return json.loads(LOCAL_VERSIONS.read_text("utf-8")) except Exception: return {} def _load_latest_store(): try: return json.loads(LATEST_STORE.read_text("utf-8")) except Exception: return {} def _save_latest_store(d): try: LATEST_STORE.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8") except Exception: pass # (navn, kind til current-version, github-repo til nyeste). Tilføj en linje = ny tjeneste. SERVICES = [ ("Authentik", "authentik", "goauthentik/authentik"), ("Gitea", "gitea", "go-gitea/gitea"), ("Gitea runner", None, "gitea:gitea.com/gitea/runner"), ("Matomo", "matomo", "matomo-org/matomo"), ("Static-assets", "assets", None), ("NPMplus", None, "ZoeyVid/NPMplus"), ("Immich", None, "immich-app/immich"), ("Mealie", None, "mealie-recipes/mealie"), ("Firefly III", None, "firefly-iii/firefly-iii"), ("Nextcloud", None, "nextcloud/server"), ("Sonarr", None, "Sonarr/Sonarr"), ("Radarr", None, "Radarr/Radarr"), ("Prowlarr", None, "Prowlarr/Prowlarr"), ("Bazarr", None, "morpheus65535/bazarr"), ("qBittorrent", None, "qbittorrent/qBittorrent"), ("Overleaf", None, "gitea-tag:gitea.weircon.dk/agw/overleaf-cep"), # cep-fork bruger tags (ikke releases) ("OpenProject", None, "opf/openproject"), ("SFTPGo", None, "drakkan/sftpgo"), ("Opengist", None, "thomiceli/opengist"), ("Home Assistant OS", None, "home-assistant/operating-system"), ("Comentario", None, "gitlab:comentario/comentario"), ("Emby", None, None), ("MariaDB", None, None), ("Proxmox VE", None, None), ] def _norm(v): if not v: return "" # behold dato-versioner (fx NPMplus 2026-04-21-r2) — strip kun v-præfiks + build-metadata return re.sub(r"^[vV]", "", str(v).strip()).split("+")[0].strip().lower() async def _latest(src, client, store): """Nyeste release-tag. src er enten 'owner/repo' (GitHub, bagud-kompat) eller 'gitlab:group/proj' / 'gitea:host/owner/repo'. `store` = sidst-kendte-gode-dict (muteres in-place ved succes; bruges som fallback ved fetch-fejl).""" if not src: return None provider, loc = src.split(":", 1) if ":" in src else ("github", src) ck = f"ver:{provider}:{loc}" hit = cache.get(ck) if hit is not None: return hit or store.get(src) or None tag = None try: if provider == "github": h = {"Accept": "application/vnd.github+json", "User-Agent": "weircon-portal"} if GITHUB_TOKEN: h["Authorization"] = f"Bearer {GITHUB_TOKEN}" r = await client.get(f"https://api.github.com/repos/{loc}/releases/latest", headers=h) tag = r.json().get("tag_name") if r.status_code < 300 else None elif provider == "gitlab": import urllib.parse pid = urllib.parse.quote(loc, safe="") r = await client.get(f"https://gitlab.com/api/v4/projects/{pid}/releases", params={"per_page": 1}, headers={"User-Agent": "weircon-portal"}) j = r.json() if r.status_code < 300 else [] tag = j[0]["tag_name"] if isinstance(j, list) and j else None elif provider == "gitea": host, repo = loc.split("/", 1) r = await client.get(f"https://{host}/api/v1/repos/{repo}/releases", params={"limit": 1}, headers={"User-Agent": "weircon-portal"}) j = r.json() if r.status_code < 300 else [] tag = j[0]["tag_name"] if isinstance(j, list) and j else None elif provider == "gitea-tag": # repos der bruger tags i stedet for releases (fx overleaf-cep). Nyeste tag. host, repo = loc.split("/", 1) r = await client.get(f"https://{host}/api/v1/repos/{repo}/tags", params={"limit": 1}, headers={"User-Agent": "weircon-portal"}) j = r.json() if r.status_code < 300 else [] tag = j[0]["name"] if isinstance(j, list) and j else None except Exception: tag = None if tag: store[src] = tag # opdatér sidst-kendte-gode cache.put(ck, tag, 3600) return tag # fetch fejlede → fald tilbage til sidst-kendte-gode (overlever rate-limit/genstart) cache.put(ck, "", 180) # prøv live igen snart return store.get(src) or None # endoflife.date-produkt pr. tjeneste (kun dem der faktisk er tracket der). EOL_PRODUCTS = {"Proxmox VE": "proxmox-ve", "Nextcloud": "nextcloud", "MariaDB": "mariadb", "Matomo": "matomo"} async def _eol(name, cur, client): """Slår kørende version op i endoflife.date → {eol-dato, state}. None hvis ikke tracket.""" prod = EOL_PRODUCTS.get(name) if not prod or not cur: return None ck = f"eol:{prod}" data = cache.get(ck) if data is None: try: r = await client.get(f"https://endoflife.date/api/{prod}.json", headers={"User-Agent": "weircon-portal", "Accept": "application/json"}) data = r.json() if r.status_code < 300 else [] except Exception: data = [] cache.put(ck, data, 43200) # 12t — EOL-data ændrer sig sjældent v = _norm(cur) best = None for c in data: cyc = str(c.get("cycle")) if v == cyc or v.startswith(cyc + "."): if best is None or len(cyc) > len(str(best.get("cycle"))): best = c if not best: return None cycle = str(best.get("cycle")) # næste major = cyklussen lige over den kørende (endoflife lister nyeste først) nxt = None try: idx = data.index(best) if idx > 0: n = data[idx - 1] nxt = {"cycle": str(n.get("cycle")), "latest": n.get("latest"), "eol": n.get("eol") if isinstance(n.get("eol"), str) else None} except Exception: nxt = None out = {"cycle": cycle, "next": nxt} eol = best.get("eol") if eol is True: out.update(date=None, state="eol") return out if eol is False or eol is None: out.update(date=None, state="supported") return out try: import datetime days = (datetime.date.fromisoformat(str(eol)) - datetime.date.today()).days out.update(date=str(eol), days=days, state="eol" if days <= 0 else ("soon" if days <= 183 else "supported")) return out except Exception: out.update(date=str(eol), state="supported") return out async def _current(kind, client): try: if kind == "authentik": r = await client.get(f"{AUTHENTIK}/api/v3/admin/version/", headers={"Authorization": f"Bearer {API_TOKEN}"}) d = r.json() return d.get("version_current"), d.get("version_latest") if kind == "gitea": r = await client.get("https://gitea.weircon.dk/api/v1/version") return r.json().get("version"), None if kind == "matomo": r = await client.get(f"{MATOMO_BASE}/index.php", params={ "module": "API", "method": "API.getMatomoVersion", "format": "JSON", "token_auth": MATOMO_TOKEN}) return r.json().get("value"), None if kind == "assets": r = await client.get(ASSETS_VERSION_URL) return r.text.strip()[:40], None except Exception: pass return None, None def _level(cur, latest): """Klassificér en tilgængelig opdatering: major / minor / patch (bugfix).""" pa = [int(x) for x in re.findall(r"\d+", _norm(cur))] pb = [int(x) for x in re.findall(r"\d+", _norm(latest))] n = max(len(pa), len(pb)) pa += [0] * (n - len(pa)) pb += [0] * (n - len(pb)) if not pa or not pb: return None if pa[0] != pb[0]: return "major" if n > 1 and pa[1] != pb[1]: return "minor" return "patch" async def _one(name, kind, repo, client, local, store): cur, lat_builtin = await _current(kind, client) if not cur: cur = local.get(name) # vært-indsamlet snapshot for LXC-tjenester latest = lat_builtin or await _latest(repo, client, store) eol = await _eol(name, cur, client) level = None if cur and latest: if _norm(cur) == _norm(latest): status = "ok" else: status = "update" level = _level(cur, latest) elif cur: status = "unknown" # ingen 'nyeste'-kilde else: status = "latest-only" # kender kun nyeste, ikke din version return {"name": name, "current": cur, "latest": latest, "status": status, "level": level, "eol": eol} ALERT_STATE = DATA / "alert_state.json" async def _maybe_alert(res, client): """Push en ntfy-besked når en NY opdatering eller EOL dukker op (ikke ved hver poll). Første kørsel seedes tavst, så man ikke får alle eksisterende på én gang.""" if not (NTFY_URL and NTFY_TOKEN): return cur, detail = set(), {} for v in res: if v["status"] == "update": sig = f"upd:{v['name']}:{_norm(v['latest'])}" cur.add(sig); detail[sig] = f"⬆ {v['name']} {v['current']} -> {v['latest']}" e = v.get("eol") if e and e.get("state") in ("eol", "soon"): sig = f"eol:{v['name']}:{e.get('date')}" cur.add(sig); detail[sig] = f"⏳ {v['name']} EOL {e.get('date')}" try: stored = set(json.loads(ALERT_STATE.read_text("utf-8"))) except Exception: stored = None if stored is None: # første kørsel: seed tavst ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8") return new = cur - stored if new: body = "\n".join(detail[s] for s in sorted(new)) try: await client.post(f"{NTFY_URL}/{NTFY_TOPIC}", headers={"Authorization": f"Bearer {NTFY_TOKEN}", "Title": f"Weircon ops: {len(new)} nye", "Tags": "package", "Click": "https://weircon.dk/admin/ops", "Icon": "https://assets.weircon.dk/static/icons/icon.png"}, content=body.encode("utf-8"), timeout=8) except Exception: pass ALERT_STATE.write_text(json.dumps(sorted(cur)), "utf-8") async def all_versions(): hit = cache.get("ver:all") if hit is not None: return hit local = _load_local() store = _load_latest_store() async with httpx.AsyncClient(timeout=8) as client: res = await asyncio.gather(*[_one(n, k, r, client, local, store) for n, k, r in SERVICES]) await _maybe_alert(res, client) _save_latest_store(store) # persistér evt. nye sidst-kendte-gode tags # Hvis "nyeste" mangler for tjenester der HAR en kilde (typisk rate-limit-blip), # cache kun kort så det prøver igen snart; ellers fuld 30-min cache. expects = {n for n, k, r in SERVICES if r or k == "authentik"} incomplete = any(v["name"] in expects and not v["latest"] for v in res) cache.put("ver:all", res, 180 if incomplete else 1800) return res