server-up/server-up/core/__init__.py
Ramon 75eb24741f
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m7s
fix: onthoud gesloten startwizard
2026-08-04 22:16:11 +02:00

381 lines
15 KiB
Python

"""Configuration — JSON backed, atomic writes."""
import json, os, re, tempfile, threading
from pathlib import Path
# Namen van stacks/instanties: streng genoeg om padtrucs uit te sluiten en
# tegelijk geldig als docker-compose projectnaam.
_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$")
# Git-transports die we toestaan. Alles daarbuiten (met name `ext::`, dat een
# shell-commando uitvoert, en `file://`) wordt geweigerd.
_URL_SCHEME_RE = re.compile(r"^(https?|ssh|git)://", re.IGNORECASE)
_SCP_SYNTAX_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[^\s]+$")
# De hoofdmap waar alles onder hangt. `docker-compose.yml` mount deze op
# zichzelf (`${BASE_DIR}:${BASE_DIR}`), dus dit is óók precies het stuk
# bestandssysteem dat Server Up kan zien. Stond hier eerder los van: de drie
# paden hieronder waren hard `/opt/serverup/*`, ook als je BASE_DIR verzette.
BASE_DIR = (os.environ.get("BASE_DIR") or "/opt/serverup").rstrip("/") or "/opt/serverup"
# Hoe de submappen heten. Eén plek, zodat de interface dezelfde afleiding kan
# tonen als de server gebruikt.
SUBMAPPEN = {"LIBRARY_DIR": "stacks", "DATA_DIR": "appdata", "BACKUP_DIR": "backups"}
def afgeleide_paden(basis: str) -> dict[str, str]:
"""De drie mappen zoals ze onder deze hoofdmap zouden heten."""
schoon = (basis or "").strip().rstrip("/")
if not schoon.startswith("/"):
return {}
return {k: f"{schoon}/{sub}" for k, sub in SUBMAPPEN.items()}
DEFAULTS = {
"BASE_DIR": BASE_DIR,
**afgeleide_paden(BASE_DIR),
"APP_REPOS": [
{"id":"server-up","name":"server-up",
"url":"https://github.com/bes-r/server-up.git","branch":"main","subdir":"apps"},
# ChristianLempa Boilerplates — automatisch herkend via template.json
{"id":"boilerplates","name":"Boilerplates (ChristianLempa)",
"url":"https://github.com/ChristianLempa/boilerplates-library.git",
"branch":"main","subdir":"compose"},
],
"MODULE_REPOS": [
{"id":"server-up","name":"server-up",
"url":"https://github.com/bes-r/server-up.git","branch":"main","subdir":"modules"},
],
"ACTIVE_MODULES": None,
"MODULE_SETTINGS": {},
"LANGUAGE": "nl",
# Tijdzone voor nieuwe apps. Leeg = die van deze server overnemen; zie
# host_timezone(). Zonder dit stond in elk sjabloon Europe/Amsterdam vast,
# ongeacht waar de server staat.
"TIMEZONE": "",
# Apps zonder eigen tijdzoneveld krijgen TZ mee, anders draaien ze in UTC
# en kloppen alle tijdstempels net niet.
"TIMEZONE_ALL_APPS": True,
"THEME": "dark",
"WIZARD_DONE": False,
# Update-check: Forgejo/GitHub Releases-API die de laatste release teruggeeft.
# Leeg = uitgeschakeld. Voorbeeld (Forgejo):
# http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases
"UPDATE_API_URL": os.environ.get("SU_UPDATE_API", ""),
"UPDATE_INCLUDE_PRERELEASE": False, # verouderd, zie UPDATE_CHANNEL
# Kanaal: "stable" (alleen releases) of "beta" (ook pre-releases).
"UPDATE_CHANNEL": os.environ.get("SU_UPDATE_CHANNEL", "stable"),
# Image waarvandaan bijgewerkt wordt, zonder tag. Leeg = afleiden uit de
# draaiende container. Bv. git.example.com/bes-r/server-up
"UPDATE_IMAGE": os.environ.get("SU_UPDATE_IMAGE", ""),
# Alleen nodig bij een privé registry.
"UPDATE_REGISTRY_USER": "",
"UPDATE_REGISTRY_TOKEN": "",
# Administratie van de laatste update (voor terugrollen).
"UPDATE_PREVIOUS_TAG": "",
"UPDATE_LAST_APPLIED": {},
# Authenticatie — beheerd via core.auth; hier alleen als placeholder zodat
# load() de sleutel kent. Wordt nooit via /api/settings teruggegeven.
"AUTH": {},
# Repo's synchroniseren bij elke start. Standaard uit: een gecompromitteerde
# upstream-repo levert anders vanzelf nieuwe templates/modules aan.
"AUTO_SYNC_ON_BOOT": False,
# Macvlan/ipvlan-netwerken waarmee stacks een eigen IP-adres krijgen.
"NETWORKS": [],
# Gedeeld bridge-netwerk waarop stacks elkaar op containernaam vinden.
# Zonder dit zit elke stack in zijn eigen compose-netwerk en kan een app
# geen andere app bereiken.
"SHARED_NETWORK": "serverup",
"CONNECT_APPS_DEFAULT": True,
# Extra bridge-netwerken die je zelf hebt aangemaakt. Eén netwerk voor
# alles betekent dat elke app elke andere kan bereiken; met meerdere kun je
# groepjes maken (media onderling, beheer apart).
"APP_NETWORKS": [],
# Backups. KEEP=0 of MAX_AGE=0 schakelt die regel uit; de nieuwste backup
# van een stack wordt nooit opgeruimd.
"BACKUP_KEEP": 5,
"BACKUP_MAX_AGE_DAYS": 0,
"BACKUP_SCHEDULE": "off", # off | daily | weekly
"BACKUP_SCHEDULE_HOUR": 3,
"BACKUP_SCHEDULE_DAY": 0, # weekly: 0 = maandag … 6 = zondag
"BACKUP_BEFORE_UPDATE": True,
"BACKUP_BEFORE_REMOVE": True,
# Controleer elk nieuw archief meteen na het maken. Kost tijd evenredig aan
# de grootte, maar een backup die je nooit leest is een aanname.
"BACKUP_VERIFY": True,
# De appdata van een stack mee in het archief. Zonder dit bevat een backup
# alleen de compose en de .env — je zet hem terug en houdt een lege app
# over. Uitzetten is nodig bij een mediabibliotheek of fotoarchief, waar dit
# in de honderden gigabytes loopt en een tar per keer geen zinnig middel is.
"BACKUP_APPDATA": True,
# Tweede bestemming voor backups: een gemounte schijf, NFS- of SMB-share.
# Leeg = uit. Backups op dezelfde schijf als de data overleven geen defect.
"BACKUP_OFFSITE_DIR": "",
# Schijfruimte. MIN_FREE_GB blijft gereserveerd (een backup die de schijf
# zou vullen wordt geweigerd); onder WARN_PCT waarschuwt de interface.
"DISK_MIN_FREE_GB": 2,
"DISK_WARN_PCT": 10,
"DISK_LOW_LAST": [], # waarover al gemeld is
# Containers bewaken die zouden moeten draaien.
"STACK_DOWN_CHECK": True,
"STACK_DOWN_LAST": {},
# Meldingen — zie core.notify. Kanaal leeg = uit.
"NOTIFY_CHANNEL": "", # "" | ntfy | webhook | email
"NOTIFY_URL": "",
"NOTIFY_TOKEN": "",
"NOTIFY_EVENTS": ["backup_failed", "backup_verify", "job_failed", "disk_low"],
"NOTIFY_EMAIL_HOST": "",
"NOTIFY_EMAIL_PORT": 587,
"NOTIFY_EMAIL_USER": "",
"NOTIFY_EMAIL_PASSWORD": "",
"NOTIFY_EMAIL_FROM": "",
"NOTIFY_EMAIL_TO": "",
"NOTIFY_EMAIL_SECURITY": "starttls", # starttls | ssl | geen
# Pangolin: publieke URL's voor je apps via een tunnel. Leeg = uit.
"PANGOLIN_URL": "",
"PANGOLIN_API_KEY": "",
"PANGOLIN_ORG_ID": "",
"PANGOLIN_SITE_ID": "",
"PANGOLIN_DOMAIN_ID": "",
# Laatste uitvoering van geplande taken (beheerd door core.scheduler).
"SCHEDULER_LAST": {},
# Updatecheck voor de geïnstalleerde stacks (image-digests).
"STACK_UPDATE_CHECK": True,
}
# Sleutels die de client noch mag lezen noch mag schrijven; AUTH bevat
# wachtwoordhashes en loopt uitsluitend via /api/auth/*.
SECRET_KEYS = frozenset({"AUTH"})
# Credentials die je wél mag instellen maar nooit terugkrijgt. Bij het opslaan
# betekent een lege waarde "ongewijzigd" — net als bij de git-tokens.
WRITE_ONLY_KEYS = frozenset({"UPDATE_REGISTRY_TOKEN", "NOTIFY_TOKEN",
"NOTIFY_EMAIL_PASSWORD", "PANGOLIN_API_KEY"})
_path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json"))
_lock = threading.RLock()
def _ensure():
try:
_path.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
def _fix_repo_url(url: str) -> str:
"""Fix GitHub URLs met pad erin: https://github.com/user/repo/modules → https://github.com/user/repo.git"""
if not url:
return url
m = re.match(r'^(https?://[^/]+/[^/]+/[^/]+?)(?:/.*)?$', url.rstrip('/'))
if m:
base = m.group(1)
if not base.endswith('.git'):
base += '.git'
return base
return url
def _sanitize_repos(repos: list) -> list:
"""Fix repo URLs in a repo list."""
for r in repos:
if isinstance(r, dict) and r.get("url"):
r["url"] = _fix_repo_url(r["url"])
return repos
_TZ_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}$")
def geldige_tijdzone(tz: str) -> bool:
"""Vorm van een IANA-zone: Europe/Amsterdam, UTC, America/Argentina/Salta."""
return bool(tz) and len(tz) <= 64 and bool(_TZ_RE.match(tz))
def host_timezone() -> str:
"""De tijdzone van deze machine.
Volgorde: de omgevingsvariabele TZ (die zet je in docker-compose mee),
/etc/timezone, en anders waar /etc/localtime naartoe wijst. Lukt niets, dan
UTC — dat is beter dan een tijdzone verzinnen.
"""
tz = (os.environ.get("TZ") or "").strip()
if geldige_tijdzone(tz):
return tz
try:
tz = Path("/etc/timezone").read_text(encoding="utf-8").strip()
if geldige_tijdzone(tz):
return tz
except OSError:
pass
try:
doel = os.path.realpath("/etc/localtime")
if "/zoneinfo/" in doel:
tz = doel.split("/zoneinfo/", 1)[1]
if geldige_tijdzone(tz):
return tz
except OSError:
pass
return "UTC"
def tijdzone() -> str:
"""De ingestelde tijdzone, of die van de server als er niets is gekozen."""
gekozen = (load().get("TIMEZONE") or "").strip()
return gekozen if geldige_tijdzone(gekozen) else host_timezone()
def safe_name(name: str) -> str | None:
"""Valideer een stack-/instantienaam. Retourneert de naam of None.
Weigert lege namen, padscheidingstekens, `..` en alles wat buiten
[a-zA-Z0-9._-] valt, zodat een naam nooit uit zijn basismap kan breken.
"""
name = (name or "").strip()
if not _SAFE_NAME_RE.match(name):
return None
if name in (".", "..") or name.startswith("-"):
return None
return name
def valid_repo_url(url: str) -> bool:
"""Alleen http(s)/ssh/git-URL's en scp-achtige `user@host:pad`-adressen.
Git ondersteunt ook `ext::<commando>`, dat letterlijk een shell-commando
uitvoert, en `file://`, dat lokale paden blootlegt. Beide horen niet thuis
in een URL die een gebruiker via de API mag opgeven.
"""
url = (url or "").strip()
if not url or len(url) > 2048:
return False
if any(c in url for c in ("\n", "\r", "\x00")):
return False
return bool(_URL_SCHEME_RE.match(url) or _SCP_SYNTAX_RE.match(url))
def redact(config: dict) -> dict:
"""Kopie van de config zonder geheimen, geschikt om aan de client te sturen.
Git-tokens worden vervangen door een `has_token`-vlag, zodat de UI kan tonen
dát er een token is zonder de waarde prijs te geven.
"""
out = {k: v for k, v in config.items() if k not in SECRET_KEYS}
for key in WRITE_ONLY_KEYS:
if key in out:
out[f"has_{key.lower()}"] = bool(str(out[key] or "").strip())
out[key] = ""
for key in ("APP_REPOS", "MODULE_REPOS"):
repos = out.get(key)
if not isinstance(repos, list):
continue
clean = []
for r in repos:
if isinstance(r, dict):
has_token = bool((r.get("token") or "").strip())
r = {k: v for k, v in r.items() if k != "token"}
r["has_token"] = has_token
clean.append(r)
out[key] = clean
return out
def load() -> dict:
_ensure()
cfg = dict(DEFAULTS)
if _path.exists():
try:
saved = json.loads(_path.read_text())
cfg.update(saved)
except Exception:
pass
# Herstel standaard repos als opgeslagen waarde leeg is
for key in ("APP_REPOS", "MODULE_REPOS"):
if not cfg.get(key) and DEFAULTS.get(key):
cfg[key] = list(DEFAULTS[key])
# Migratie: vul ontbrekende default-repos eenmalig aan (bij upgrade van oudere
# versie). Markeer welke migraties al gedraaid zijn — zo komt een door de
# gebruiker verwijderde repo niet automatisch terug.
done = set(cfg.get("MIGRATIONS_DONE") or [])
migrated = False
if "v0.3.0_default_repos" not in done:
for key in ("APP_REPOS", "MODULE_REPOS"):
existing = {r.get("id") for r in (cfg.get(key) or []) if isinstance(r, dict)}
for d in DEFAULTS.get(key, []) or []:
if d.get("id") and d["id"] not in existing:
cfg[key].append(dict(d))
migrated = True
done.add("v0.3.0_default_repos")
cfg["MIGRATIONS_DONE"] = sorted(done)
if migrated and _path.exists():
try:
with _lock:
fd, tmp = tempfile.mkstemp(dir=str(_path.parent), suffix=".json")
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2)
Path(tmp).replace(_path)
except Exception:
pass
# Fix foute URLs
for key in ("APP_REPOS", "MODULE_REPOS"):
if cfg.get(key):
_sanitize_repos(cfg[key])
# Bescherm tegen onveilige paden
_unsafe = {"", "/", "/app", "/app/", "/etc", "/bin", "/usr", "/var", "/tmp", "/root"}
for key in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
val = cfg.get(key, "").rstrip("/")
if val in _unsafe or not val.startswith("/"):
cfg[key] = DEFAULTS[key]
# Env-overrides gelden alleen voor eenvoudige waarden; lijsten en objecten
# (APP_REPOS, AUTH, NETWORKS, …) zouden anders een kale string worden.
for k, default in DEFAULTS.items():
if k in os.environ and not isinstance(default, (dict, list)):
cfg[k] = os.environ[k]
# Herhaal veiligheidscheck na env-overrides
for key in ("LIBRARY_DIR", "DATA_DIR", "BACKUP_DIR"):
val = cfg.get(key, "").rstrip("/")
if val in _unsafe or not val.startswith("/"):
cfg[key] = DEFAULTS[key]
# Injecteer SU_BOOT_REPOS in geheugen (worden niet automatisch opgeslagen)
boot_raw = os.environ.get("SU_BOOT_REPOS", "").strip()
if boot_raw:
try:
extra = json.loads(boot_raw)
existing_ids = {r.get("id") for r in cfg.get("APP_REPOS", [])}
for r in extra:
if isinstance(r, dict) and r.get("id") and r["id"] not in existing_ids:
cfg["APP_REPOS"] = list(cfg.get("APP_REPOS", [])) + [r]
existing_ids.add(r["id"])
except Exception:
pass
return cfg
def save(cfg: dict):
_ensure()
# Sanitize voor opslaan
for key in ("APP_REPOS", "MODULE_REPOS"):
if cfg.get(key):
_sanitize_repos(cfg[key])
with _lock:
fd, tmp = tempfile.mkstemp(dir=str(_path.parent), suffix=".json")
try:
# De config bevat git-tokens en wachtwoordhashes — alleen leesbaar
# voor de eigenaar. mkstemp geeft al 0600; expliciet is duidelijker.
os.chmod(tmp, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2)
Path(tmp).replace(_path)
except Exception:
try:
Path(tmp).unlink(missing_ok=True)
except Exception:
pass
raise
def patch(updates: dict) -> dict:
with _lock:
cfg = load()
cfg.update(updates)
save(cfg)
return cfg