All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m16s
Apps onderling verbinden:
- Elke stack draaide als eigen compose-project met een eigen netwerk, waardoor
stacks elkaar niet op naam konden bereiken. Bij installeren staat nu
"Verbinden met andere apps" aan: de stack komt op een gedeeld bridge-netwerk
(SHARED_NETWORK, standaard "serverup") dat op aanvraag wordt aangemaakt.
- compose_transform.add_shared_network() hangt elke service eraan en laat
bestaande netwerken staan, dus het werkt samen met een macvlan-adres.
- Nieuw /api/apps/connectable geeft de geinstalleerde apps met hun
containernamen, zodat de installatiemodal kan laten kiezen.
Kiezen in plaats van typen:
- Een veld met "connect": {"app": "mosquitto", "scheme": "mqtt", "port": 1883}
toont een keuzelijst met wat er al draait, met handmatig invullen als
terugval. Toegepast op zigbee2mqtt -> mosquitto en open-webui -> ollama.
- Is de doel-app nog niet geinstalleerd, dan zegt het veld dat.
Uitleg per instelling:
- Info-icoon per veld opent een blok met de volledige uitleg plus de
standaardwaarde. Uitgeschreven voor de instellingen die de meeste vragen
oproepen (downloadmap vs mediamap, shm_size bij frigate, poort 80 bij caddy,
PUID bij de arr-stack, GPU bij ollama, publiek adres bij wg-easy).
Geavanceerde opties:
- advanced: true op een veld of groep zet het achter een uitklapblok met
teller. Toegepast op wachtwoorden, PUID/PGID, USB-paden en bewaartermijnen
van 28 apps.
- Test bewaakt dat elke app zichtbare velden overhoudt en dat koppelvelden naar
een bestaande app verwijzen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
276 lines
11 KiB
Python
276 lines
11 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]+$")
|
|
|
|
DEFAULTS = {
|
|
"LIBRARY_DIR": "/opt/serverup/stacks",
|
|
"DATA_DIR": "/opt/serverup/appdata",
|
|
"BACKUP_DIR": "/opt/serverup/backups",
|
|
"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",
|
|
"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,
|
|
# 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,
|
|
# 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"})
|
|
|
|
_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
|
|
|
|
|
|
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]
|
|
# Auto-reset wizard als LIBRARY_DIR niet bestaat (verse installatie met oud volume)
|
|
if cfg.get("WIZARD_DONE") and not Path(cfg["LIBRARY_DIR"]).exists():
|
|
cfg["WIZARD_DONE"] = False
|
|
# 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
|