Proxmox VE får 'nyeste' fra værtens apt-kandidat, ny sektion med sikkerhedspakker/genstartsbehov, ntfy ved nye sikkerhedsopdateringer. _norm stripper release-præfiks (qBittorrent). Claude-Session: https://claude.ai/code/session_01AUj1LSMmrowcomkn1ciAgj
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""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å
|
|
# 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-/release-præfiks + build-metadata
|
|
return re.sub(r"^(release-)?[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 i snapshot'et = nyeste fra værten selv (fx apt-kandidat for pve-manager)
|
|
latest = lat_builtin or await _latest(repo, client, store) or local.get("_latest", {}).get(name)
|
|
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')}"
|
|
h = host_updates()
|
|
if h and h.get("security"):
|
|
sig = "sec:host:" + ",".join(sorted(f"{p['name']}={p['to']}" for p in h["security_pkgs"]))
|
|
cur.add(sig); detail[sig] = f"🔒 Proxmox-vært: {h['security']} sikkerhedsopdateringer"
|
|
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")
|
|
|
|
|
|
def host_updates():
|
|
"""Værtens apt-status (indsamlet af portal-versions.py) eller None."""
|
|
return _load_local().get("_host")
|
|
|
|
|
|
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
|