diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ce5f5..055463d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,59 @@ +# v0.7.20-beta β€” Instellingen wijzigen, koppelen achteraf, en een defect uit v0.7.00 + +## πŸ”΄ Gedeeld netwerk botste met `network_mode` + +De koppeling uit v0.7.00 hing Γ©lke service aan het gedeelde netwerk. Maar een +service met `network_mode` deelt al andermans namespace, en docker compose +weigert die combinatie. Met de standaardkeuze "Verbinden met andere apps" waren +**Homebridge**, **Scrypted**, **Beszel** en de **ARR-stack met Gluetun** daardoor +niet te installeren. + +Services met `network_mode` worden nu overgeslagen. Een test past het gedeelde +netwerk toe over alle 96 apps en controleert dat die combinatie nooit ontstaat. + +## βš™οΈ Instellingen wijzigen na installatie + +Je koos bij het installeren netjes een poort, map en wachtwoord β€” maar daarna was +er geen weg terug naar dat formulier. Voor een andere poort moest je het +compose-bestand met de hand bewerken. + +De knop **Instellingen** op elke stackkaart opent hetzelfde formulier, voorgevuld +met wat je destijds hebt gekozen. Opslaan maakt eerst een backup, rendert de +stack opnieuw, valideert en herstart. Loopt het valideren mis, dan wordt de +backup automatisch teruggezet. + +Stacks die zijn geΓ―nstalleerd voordat Server Up die keuzes bewaarde, hebben de +knop uit staan met uitleg waarom. + +## πŸ”— Bestaande stacks alsnog koppelen + +Het gedeelde netwerk gold alleen voor nieuwe installaties. Een knop op de +stackkaart zet een bestaande stack er alsnog op, valideert en herstart. Wordt de +compose ongeldig, dan wordt de wijziging teruggedraaid. + +## πŸ” Audit-log doorzoekbaar + +Het log toonde de laatste tweehonderd regels zonder filter, terwijl er inmiddels +logins, rolwijzigingen, backups en updates in landen. Nu filteren op bron, actie, +status en periode, met vrij zoeken door referentie, details en IP-adres, en +doorbladeren voorbij de eerste honderd. + +## πŸ“– Documentatie + +- **`README.md`** β€” die was er niet. Wat Server Up is, installeren, het eerste + account, en waar je verder moet kijken. +- **`docs/apps-maken.md`** β€” het volledige templateformaat: velden, geavanceerde + opties, uitleg per veld, apps aan elkaar koppelen, onderdelen optioneel maken + met groepsschakelaars, en de valkuilen (named volumes, poorten, `network_mode`). +- **`docs/README.md`** β€” index over de zeven documenten. + +De changelog-secties voor `v0.5.44`, `v0.5.45` en `v0.5.46` ontbraken; omdat +`release.yml` de notes daaruit haalt, zou een tag op die versies een lege release +opleveren. Aangevuld, met een test die afdwingt dat het huidige `VERSION` altijd +een sectie heeft. + +--- + # v0.7.10-beta β€” CategorieΓ«n met kleur, in de app store en bij je apps Alle 96 apps zijn ingedeeld in zeventien categorieΓ«n, elk met een eigen kleur en @@ -250,6 +306,51 @@ door de test die precies zo'n container als laatste zet. --- +# v0.5.46-beta β€” Deploy strandde op een .env van een andere gebruiker + +De stap die instellingen klaarzet faalde op `touch: cannot touch '.env': +Permission denied`. Het bestand was met sudo aangemaakt en dus van root, terwijl +de runner als gewone gebruiker draait β€” waarmee de hele deploy afbrak op iets +dat geen blokkade hoort te zijn. + +- `SU_TAG` gaat nu altijd via de omgeving naar `docker compose`. Wegschrijven in + `.env` blijft de voorkeur omdat het een handmatige `docker compose up` + overleeft, maar is niet meer nodig om te kunnen deployen. +- Is `.env` niet schrijfbaar, dan meldt de log wie de eigenaar is en welk + `chown`-commando dat rechtzet, en loopt de deploy door. + +--- + +# v0.5.45-beta β€” Wizard-knop toegevoegd + +De wizard was alleen zichtbaar bij een verse installatie: zodra hij een keer was +afgerond, was er geen enkele manier meer om hem te openen. De vertaalsleutels +`wizard_open` en `wizard_reset` bestonden al en `/api/wizard/reset` werkte ook, +maar de knop is nooit gebouwd β€” ook niet in v0.4.60. + +- Blok **Setup Wizard** onder Instellingen met "Wizard openen" en "Opnieuw + uitvoeren". +- Sluitknop in de wizard zelf; die kon je eerder alleen verlaten door hem + helemaal af te ronden. +- De staptitels lopen nu via `t()` in plaats van harde Nederlandse teksten. + +--- + +# v0.5.44-beta β€” Gedetecteerd netwerk verdween uit het formulier + +Zodra je een netwerk had toegevoegd, was het blok "gevonden op deze server" leeg +bij een volgende poging: subnetten die al geconfigureerd waren werden uit de +suggesties gefilterd. Dat pakt verkeerd uit β€” je kunt prima een tweede netwerk op +hetzelfde subnet willen met een andere range, en zonder dat blok lijkt het +formulier kapot. + +- Gedetecteerde netwerken blijven staan en tonen "al toegevoegd" in plaats van + te verdwijnen. +- Levert de detectie niets op zonder dat er een fout is, dan staat er nu uitleg + in plaats van een leeg blok. + +--- + # v0.5.43-beta β€” Van-tot omrekenen naar een CIDR-blok "Ik wil van 10.0.20.200 tot .254" is een volstrekt redelijke wens, maar bestaat diff --git a/README.md b/README.md new file mode 100644 index 0000000..136c257 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Server Up + +Zelfgehoste beheerinterface voor Docker: apps installeren uit een catalogus, +stacks starten en stoppen, backups maken en terugzetten, en containers een eigen +IP-adres in je netwerk geven. + +> ⚠️ **Toegang tot Server Up staat gelijk aan root op de host.** De applicatie +> beheert de Docker-daemon, en wie containers kan starten kan willekeurige mappen +> van de host mounten. Behandel de interface navenant: maak direct een account +> aan, zet hem niet zonder reverse proxy met TLS op internet, en lees +> [docs/beveiliging.md](docs/beveiliging.md). + +--- + +## Wat het doet + +| | | +|---|---| +| **App-catalogus** | 96 kant-en-klare apps, ingedeeld in categorieΓ«n met kleur. Zoeken, filteren en installeren met een formulier in plaats van YAML. | +| **Stacks beheren** | Starten, stoppen, bijwerken en herstarten β€” per stack of per losse container, met logs en live CPU-/geheugengebruik. | +| **Apps koppelen** | Stacks komen op een gedeeld netwerk, zodat ze elkaar op naam bereiken. Een veld dat naar een andere app verwijst toont een keuzelijst met wat je al draait. | +| **Eigen IP per stack** | Met macvlan of ipvlan krijgt een app een eigen adres in je LAN in plaats van poorten op de host. | +| **Backups** | Handmatig, gepland, en automatisch vΓ³Γ³r een update of verwijdering. Met terugzetten en een bewaarbeleid. | +| **Updates** | Voor Server Up zelf (stable of beta) en voor de images van je geΓ―nstalleerde stacks. | +| **Toegang** | Lokale accounts met rollen (beheerder, operator, alleen lezen), of SSO via een reverse proxy. | + +## Wat je nodig hebt + +- Een Linux-server met Docker en de compose-plugin +- Toegang tot `/var/run/docker.sock` +- Ongeveer 300 MB schijfruimte voor de applicatie zelf + +## Installeren + +```bash +git clone https://github.com/bes-r/server-up.git +cd server-up +cp .env.example .env # instellingen; zie hieronder +docker compose up -d --build +``` + +Open daarna `http://localhost:5000` en **maak meteen een beheerdersaccount aan.** +Zolang dat niet gebeurd is, kan iedereen die de pagina bereikt het account +claimen β€” de container waarschuwt daar bij het opstarten ook voor. + +### Instellingen + +Alles staat met uitleg in [`.env.example`](.env.example). De twee die je +waarschijnlijk meteen nodig hebt: + +```bash +BIND=127.0.0.1 # 0.0.0.0 om er van buiten de server bij te kunnen +PORT=5000 +``` + +De poort bindt standaard op `127.0.0.1`. Zet hem pas open als er een reverse +proxy met TLS voor staat. + +## Documentatie + +| Document | Waarover | +|---|---| +| [docs/beveiliging.md](docs/beveiliging.md) | Accounts, rollen, SSO, reverse proxy, wat wel en niet is afgedekt | +| [docs/apps-maken.md](docs/apps-maken.md) | Eigen apps toevoegen: het volledige templateformaat | +| [docs/netwerken.md](docs/netwerken.md) | Stacks een eigen IP geven met macvlan of ipvlan | +| [docs/backups.md](docs/backups.md) | Wat er wΓ©l en niet in een backup zit, terugzetten, bewaarbeleid | +| [docs/updates.md](docs/updates.md) | Update-kanalen en de Forgejo-registry inrichten | +| [docs/synchroniseren.md](docs/synchroniseren.md) | Code van je apparaat naar de server via Forgejo | +| [docs/forgejo-actions-setup.md](docs/forgejo-actions-setup.md) | Runners instellen | + +## Eigen apps toevoegen + +Wijs Server Up naar je eigen git-repo met app-sjablonen (Instellingen β†’ App +Store β†’ Repository), of voeg een map toe onder `apps/`: + +``` +apps/mijn-app/ +β”œβ”€β”€ template.json metadata en de vragen bij het installeren +└── files/ + └── compose.yaml het compose-bestand met << variabelen >> +``` + +Het volledige formaat staat in [docs/apps-maken.md](docs/apps-maken.md). + +## Ontwikkelen + +```bash +python3 -m venv venv && venv/bin/pip install -r server-up/requirements.txt pytest +venv/bin/python -m pytest tests -q # de volledige suite +cd server-up && SU_DEV=1 python app.py # lokaal draaien op poort 5000 +``` + +De testsuite draait ook over alle app-sjablonen: elke app moet renderen naar +geldige compose-YAML, met gedeclareerde volumes en zonder onvervangen +variabelen. Voeg je een app toe, dan wordt die automatisch meegenomen. + +De deploy-workflows draaien de tests in een container (`python:3.12-slim`), dus +op de runner is verder niets nodig dan Docker. + +## Licentie en bijdragen + +Zie `CHANGELOG.md` voor de geschiedenis. Werk op de `dev`-branch en bump +`VERSION` bij elke wijziging; een test bewaakt dat er een changelog-sectie bij +hoort. diff --git a/VERSION b/VERSION index f7580d2..944e688 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.10-beta +0.7.20-beta diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..5eeff25 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,14 @@ +# Documentatie + +| Document | Waarover, en wanneer je het nodig hebt | +|---|---| +| [beveiliging.md](beveiliging.md) | Accounts, rollen, reverse-proxy-SSO en TLS. **Lees dit voordat je Server Up van buiten je netwerk bereikbaar maakt** β€” toegang tot de interface staat gelijk aan root op de host. | +| [apps-maken.md](apps-maken.md) | Eigen apps toevoegen: het volledige `template.json`-formaat, de compose-syntaxis, apps aan elkaar koppelen en onderdelen optioneel maken. | +| [netwerken.md](netwerken.md) | Stacks een eigen IP-adres geven in je LAN met macvlan of ipvlan. Inclusief de valkuil dat de host zijn eigen macvlan-containers niet bereikt. | +| [backups.md](backups.md) | Wat er wΓ©l en niet in een backup zit, terugzetten, bewaarbeleid en geplande backups. | +| [updates.md](updates.md) | Update-kanalen (stable en beta), bijwerken vanuit de interface, en de Forgejo-registry inrichten. | +| [synchroniseren.md](synchroniseren.md) | Code van je eigen apparaat automatisch naar de server krijgen via Forgejo. | +| [forgejo-actions-setup.md](forgejo-actions-setup.md) | De act_runner installeren en instellen. | + +Zie [`../README.md`](../README.md) voor installeren en een overzicht van wat +Server Up doet. diff --git a/docs/apps-maken.md b/docs/apps-maken.md new file mode 100644 index 0000000..e0a57f8 --- /dev/null +++ b/docs/apps-maken.md @@ -0,0 +1,305 @@ +# Eigen apps toevoegen + +Een app in Server Up is een map met twee dingen: `template.json` beschrijft wat +de app is en welke vragen je bij het installeren krijgt, en `files/compose.yaml` +is het compose-bestand met plaatshouders erin. + +``` +apps/mijn-app/ +β”œβ”€β”€ template.json +└── files/ + └── compose.yaml +``` + +Zet die map in `apps/`, of in je eigen git-repo die je toevoegt bij +**Instellingen β†’ App Store β†’ Repository**. + +--- + +## Het kortste voorbeeld dat werkt + +**`template.json`** + +```json +{ + "kind": "compose", + "metadata": { + "name": "Mijn App", + "description": "Wat de app doet, in één zin.", + "tags": ["voorbeeld"], + "categories": ["productiviteit"], + "icon": {"provider": "selfhst", "id": "mijn-app"}, + "version": {"name": "latest"} + }, + "variables": [ + { + "title": "Algemeen", + "items": [ + {"name": "service_name", "type": "str", "title": "Servicenaam", + "default": "mijn-app", "required": true}, + {"name": "port", "type": "int", "title": "Web poort", + "default": 8200, "required": true}, + {"name": "data_dir", "type": "str", "title": "Data directory", + "default": "/opt/serverup/appdata", "required": true}, + {"name": "timezone", "type": "str", "title": "Tijdzone", + "default": "Europe/Amsterdam", "required": true} + ] + } + ] +} +``` + +**`files/compose.yaml`** + +```yaml +services: + << service_name >>: + image: voorbeeld/mijn-app:latest + container_name: << service_name >> + ports: + - "<< port >>:8080" + environment: + - TZ=<< timezone >> + volumes: + - << data_dir >>/<< service_name >>/config:/config + restart: unless-stopped +``` + +Dat is genoeg. De rest van dit document beschrijft wat je er nog meer mee kunt. + +--- + +## Compose-syntaxis + +De sjablonen gebruiken Jinja met afwijkende haakjes, zodat ze niet botsen met +de `${VAR}` van docker compose zelf: + +| | | +|---|---| +| `<< variabele >>` | waarde invullen | +| `<%- if variabele %>` … `<%- endif %>` | blok alleen opnemen als de waarde waar is | +| `<< waarde \| lower >>` | filters van Jinja werken gewoon | + +Het renderen gebeurt in een sandbox: je kunt niet bij Python-internals. + +### Named volumes moeten gedeclareerd worden + +Gebruik je een named volume, zet het dan Γ³Γ³k in het top-level `volumes:`-blok. +Vergeet je dat, dan weigert docker compose met *"refers to undefined volume"*: + +```yaml +services: + << service_name >>-db: + image: postgres:16-alpine + volumes: + - << service_name >>_pgdata:/var/lib/postgresql/data + +volumes: + << service_name >>_pgdata: +``` + +De testsuite controleert dit voor elke app. + +--- + +## Metadata + +| Veld | Betekenis | +|---|---| +| `name` | Naam zoals getoond in de catalogus | +| `description` | EΓ©n zin: wat is het, en waarvoor gebruik je het | +| `tags` | Vrije trefwoorden, gebruikt voor zoeken en filteren | +| `categories` | EΓ©n of meer categorie-ids (zie onder). Ontbreken ze, dan worden ze afgeleid uit de tags | +| `icon` | `{"provider": "selfhst", "id": "…"}` of `dashboard-icons`. Bestaat het icoon niet, dan valt de interface terug op een emoji | +| `version` | Alleen ter informatie in de catalogus | + +### CategorieΓ«n + +Een app mag in meerdere categorieΓ«n zitten. De ids: + +`media` Β· `fotos` Β· `downloaden` Β· `domotica` Β· `3d-printen` Β· `netwerk` Β· +`beveiliging` Β· `opslag` Β· `documenten` Β· `productiviteit` Β· `communicatie` Β· +`financien` Β· `gezin` Β· `monitoring` Β· `ontwikkeling` Β· `ai` Β· `beheer` Β· +`overig` + +Elke categorie heeft een vaste kleur die overal in de interface terugkomt. De +definities staan in `server-up/core/categories.py`. + +--- + +## Velden + +Elk item onder `items` wordt één vraag in het installatieformulier. + +```json +{ + "name": "port", + "type": "int", + "title": "Web poort", + "default": 8200, + "required": true, + "description": "Korte tekst onder het veld", + "help": "Langere uitleg, zichtbaar achter het info-icoon", + "advanced": false, + "config": {"placeholder": "bv. 8200", "options": ["a", "b"]} +} +``` + +| Type | Wordt | +|---|---| +| `str` | tekstveld | +| `int` | getalveld | +| `bool` | aanvinkvakje | +| `enum` | keuzelijst, met de waarden uit `config.options` | + +### Vaste namen + +Deze namen worden speciaal behandeld: + +- **`service_name`** β€” verplicht. Wordt de containernaam en het voorvoegsel van + alles in de stack. Wordt automatisch gevuld met de instantienaam. +- **Velden met `port` in de naam en type `int`** β€” krijgen bij het installeren + automatisch een vrij poortnummer voorgesteld als de standaard al bezet is. +- **Velden met `token`, `password`, `secret` of `key` in de naam** β€” krijgen een + knop die een willekeurige waarde genereert. + +### Geavanceerde velden + +`"advanced": true` zet een veld achter het uitklapblok *Geavanceerde opties*. +Bedoeld voor wat je meestal met rust laat: database-wachtwoorden, PUID/PGID, +USB-paden, bewaartermijnen. Zet niet Γ‘lles op geavanceerd β€” een test controleert +dat er per app zichtbare velden overblijven. + +Je kunt het ook op een hele groep zetten: `{"title": "…", "advanced": true, …}`. + +### Uitleg + +`description` staat als kleine tekst onder het veld. `help` is de langere uitleg +achter het info-icoon; ontbreekt die, dan wordt `description` getoond. Gebruik +`help` voor dingen die een gebruiker echt moet weten: + +```json +{ + "name": "downloads_dir", + "type": "str", + "title": "Downloadmap", + "default": "/mnt/downloads", + "help": "Zet deze map op hetzelfde bestandssysteem als je mediamap. Staan ze op verschillende volumes, dan kopieert elke voltooide download in plaats van te verplaatsen." +} +``` + +### Velden afhankelijk maken + +`needs` verbergt een veld tot een ander veld een bepaalde waarde heeft: + +```json +{"name": "smtp_host", "type": "str", "title": "SMTP-server", + "needs": ["mail_enabled == true"]} +``` + +--- + +## Apps aan elkaar koppelen + +Verwijst een veld naar een andere app, zet er dan `connect` op. De interface +toont dan een keuzelijst met wat er al geΓ―nstalleerd is, in plaats van een leeg +tekstveld: + +```json +{ + "name": "mqtt_server", + "type": "str", + "title": "MQTT-server", + "default": "mqtt://mosquitto:1883", + "connect": {"app": "mosquitto", "scheme": "mqtt", "port": 1883}, + "help": "Heb je de Mosquitto-app geΓ―nstalleerd, kies die dan hier." +} +``` + +| Sleutel | Betekenis | +|---|---| +| `app` | De mapnaam van de app waarnaar verwezen wordt (`apps/mosquitto`) | +| `scheme` | Wat voor het adres komt: `http`, `mqtt`, … | +| `port` | Poort binnen het gedeelde netwerk (de interne poort, niet die op de host) | +| `path` | Optioneel pad achter het adres | + +Dat levert bijvoorbeeld `mqtt://mosquitto:1883` op. Staat de app er nog niet, dan +meldt het veld dat en kun je alsnog handmatig invullen. + +**Let op de poort:** dit gaat over de poort *binnen* de container, niet de poort +die op de host gepubliceerd is. De stacks zitten samen op een gedeeld netwerk en +bereiken elkaar op containernaam. + +--- + +## Onderdelen optioneel maken + +Voor stacks met meerdere componenten kun je per groep een schakelaar zetten: + +```json +{ + "title": "Bazarr β€” ondertitels", + "toggle": "enable_bazarr", + "toggle_default": true, + "description": "Haalt automatisch ondertitels op", + "items": [ + {"name": "port_bazarr", "type": "int", "title": "Poort", "default": 6767} + ] +} +``` + +In het compose-bestand gebruik je die schakelaar als voorwaarde: + +```yaml +<%- if enable_bazarr %> + << service_name >>-bazarr: + image: lscr.io/linuxserver/bazarr:latest + ports: + - "<< port_bazarr >>:6767" +<%- endif %> +``` + +`toggle_default` bepaalt of het onderdeel standaard aan staat. Zonder die sleutel +staat hij aan. + +De ARR-stack (`apps/arr-stack`) gebruikt dit voor twintig onderdelen en is het +beste voorbeeld om van af te kijken. + +--- + +## Waar je op moet letten + +**Poorten.** Kies een standaardpoort die nog niet door een andere app gebruikt +wordt. De testsuite controleert dat, en meldt welke botsen. Alternatieven voor +dezelfde taak (twee reverse proxies, twee DNS-blokkers) mogen wel dezelfde poort +delen; die staan in een allowlist in `tests/test_apps.py`. + +**`network_mode`.** Heeft een service `network_mode: host` of +`network_mode: service:…`, dan kan hij niet aan het gedeelde netwerk hangen. +Server Up slaat die services over, dus het werkt β€” maar zo'n app kan andere apps +niet op naam bereiken. + +**Images.** Gebruik een image waarvan je zeker weet dat hij bestaat en welke +architecturen hij ondersteunt. De testsuite controleert wel de vorm van je +compose, maar haalt geen images op. + +--- + +## Testen + +Elke app in `apps/` wordt automatisch meegenomen in de testsuite: + +```bash +python -m pytest tests/test_apps.py -q +``` + +Gecontroleerd wordt: geldige metadata, renderen naar geldige YAML, elke service +heeft een `image` of `build`, named volumes zijn gedeclareerd, geen onvervangen +`<< variabelen >>`, geen onbedoeld dubbele poorten, geldige categorieΓ«n, en dat +`network_mode` niet botst met het gedeelde netwerk. + +Wil je alleen jouw app zien: + +```bash +python -m pytest tests/test_apps.py -q -k mijn-app +``` diff --git a/server-up/app.py b/server-up/app.py index 2da016a..c50b17a 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -621,6 +621,8 @@ def api_stacks(): "network": meta.get("network", ""), "ip": meta.get("ip", ""), # Bij installatie vastgelegd; ontbreekt dat (oudere installatie), # dan leiden we het alsnog af uit de opgeslagen naam. + "connected": bool(meta.get("connected")), + "editable": bool(meta.get("values")), "categories": categories.normalize( meta.get("categories"), meta.get("tags"), meta.get("name", "")), }) @@ -766,6 +768,202 @@ def api_stack_compose_put(name): return jsonify(ok=True) +def _stack_meta_lezen(d: Path) -> dict: + mf = d / ".serverup.json" + if mf.exists(): + try: + return json.loads(mf.read_text("utf-8")) + except Exception: + pass + return {} + + +def _stack_meta_schrijven(d: Path, updates: dict): + meta = _stack_meta_lezen(d) + meta.update(updates) + (d / ".serverup.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + + +@app.route("/api/stacks//config") +def api_stack_config(name): + """Opgeslagen installatiekeuzes plus het actuele veldschema. + + Daarmee kan de interface hetzelfde formulier opnieuw tonen in plaats van de + gebruiker het compose-bestand met de hand te laten bewerken. + """ + lib, d = _stack_dir(name) + if not d: + return jsonify(ok=False, msg="ongeldige naam"), 400 + if not d.exists(): + return jsonify(ok=False, msg="niet gevonden"), 404 + + meta = _stack_meta_lezen(d) + waarden = meta.get("values") or {} + bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", "")) + if not waarden or not bron: + return jsonify( + ok=False, editable=False, + msg=("Deze stack is ge\u00efnstalleerd voordat Server Up de gemaakte " + "keuzes bewaarde, of het bronsjabloon is niet meer beschikbaar. " + "Wijzigen kan via 'compose bewerken'.")) + return jsonify(ok=True, editable=True, + fields=_annotate_fields(boilerplates.fields(bron)), + values=waarden, + network=meta.get("network", ""), ip=meta.get("ip", ""), + connected=bool(meta.get("connected")), + source=meta.get("source", ""), name=meta.get("name", name)) + + +@app.route("/api/stacks//reconfigure", methods=["POST"]) +def api_stack_reconfigure(name): + """Render de stack opnieuw met gewijzigde waarden. + + Maakt eerst een backup; loopt het renderen of valideren mis, dan wordt die + teruggezet zodat je nooit met een half werkende stack achterblijft. + """ + lib, d = _stack_dir(name) + if not d or not d.exists(): + return jsonify(ok=False, msg="niet gevonden"), 404 + + body = request.json or {} + nieuwe_waarden = body.get("values") or {} + meta = _stack_meta_lezen(d) + bron = _find_stack_src(meta.get("source", ""), meta.get("repo_id", "")) + if not bron: + return jsonify(ok=False, msg="Bronsjabloon niet gevonden"), 400 + + net_name = (body.get("network") if "network" in body else meta.get("network")) or "" + net_ip = (body.get("ip") if "ip" in body else meta.get("ip")) or "" + verbinden = body.get("connect_apps") + if verbinden is None: + verbinden = bool(meta.get("connected")) + gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip() + + jid, q = jobs.create(f"reconfigure:{name}") + + def _run(jid): + qq = jobs.get_queue(jid) + lf = lambda m: jobs.log(qq, "dim", m) + backup_naam = "" + try: + jobs.log(qq, "section", f"Instellingen wijzigen: {name}") + try: + backup_naam = backups.create(name, reden="voor-herconfiguratie", + log_fn=lf)["file"] + except Exception as e: + jobs.log(qq, "error", f"Backup mislukt, wijziging afgebroken: {e}") + jobs.finish(jid, "error"); jobs.done(qq); return + + boilerplates.render_to_dir(bron, d, nieuwe_waarden) + jobs.log(qq, "ok", "Opnieuw gerenderd") + + cf = docker.find_compose(d) + if net_name: + net = _network_by_name(net_name) + if net and net_ip: + cf.write_text(compose_transform.to_dedicated_ip( + cf.read_text(encoding="utf-8"), net_name, net_ip), + encoding="utf-8") + jobs.log(qq, "ok", f"Eigen IP-adres opnieuw toegepast: {net_ip}") + if verbinden and gedeeld_net: + ok_net, _ = networks.ensure_shared(gedeeld_net) + if ok_net: + cf.write_text(compose_transform.add_shared_network( + cf.read_text(encoding="utf-8"), gedeeld_net), encoding="utf-8") + jobs.log(qq, "ok", f"Gekoppeld aan '{gedeeld_net}'") + + ok_cfg, err = docker.validate_compose(d) + if not ok_cfg: + jobs.log(qq, "error", f"Compose is ongeldig:\n{err}") + jobs.log(qq, "dim", "Backup wordt teruggezet\u2026") + backups.restore(name, backup_naam, log_fn=lf) + jobs.finish(jid, "error"); jobs.done(qq); return + + _stack_meta_schrijven(d, { + "values": nieuwe_waarden, "network": net_name, "ip": net_ip, + "connected": bool(verbinden and gedeeld_net)}) + + docker.compose_down(d, log_fn=lf, name=name) + rc = docker.compose_up(d, log_fn=lf, name=name) + jobs.log(qq, "ok" if rc == 0 else "error", + "Herstart met de nieuwe instellingen" if rc == 0 + else "Starten mislukt \u2014 zet de backup terug via Backups") + audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error", + ref=name, ip=request.remote_addr) + jobs.finish(jid, "done" if rc == 0 else "error") + except Exception as e: + jobs.log(qq, "error", f"{type(e).__name__}: {e}") + if backup_naam: + jobs.log(qq, "dim", "Backup wordt teruggezet\u2026") + try: + backups.restore(name, backup_naam, log_fn=lf) + except Exception: + pass + jobs.finish(jid, "error") + jobs.done(qq) + + jobs.run(_run, jid) + return jsonify(ok=True, job_id=jid) + + +@app.route("/api/stacks//connect", methods=["POST"]) +def api_stack_connect(name): + """Zet een bestaande stack op het gedeelde netwerk (of haal hem eraf). + + Stacks die al bestonden voordat de koppeling er was, zaten alleen in hun + eigen compose-netwerk en konden geen andere app bereiken. + """ + lib, d = _stack_dir(name) + if not d or not d.exists(): + return jsonify(ok=False, msg="niet gevonden"), 404 + aan = bool((request.json or {}).get("connect", True)) + gedeeld_net = (cfg.load().get("SHARED_NETWORK") or "serverup").strip() + + jid, q = jobs.create(f"connect:{name}") + + def _run(jid): + qq = jobs.get_queue(jid) + lf = lambda m: jobs.log(qq, "dim", m) + try: + if not aan: + jobs.log(qq, "warn", + "Loskoppelen gaat via 'compose bewerken': het " + "networks-blok weghalen en de stack herstarten.") + jobs.finish(jid, "done"); jobs.done(qq); return + + jobs.log(qq, "section", f"Koppelen: {name}") + ok_net, fout = networks.ensure_shared(gedeeld_net) + if not ok_net: + jobs.log(qq, "error", f"Netwerk '{gedeeld_net}' niet beschikbaar: {fout}") + jobs.finish(jid, "error"); jobs.done(qq); return + + cf = docker.find_compose(d) + origineel = cf.read_text(encoding="utf-8") + cf.write_text(compose_transform.add_shared_network(origineel, gedeeld_net), + encoding="utf-8") + ok_cfg, err = docker.validate_compose(d) + if not ok_cfg: + cf.write_text(origineel, encoding="utf-8") + jobs.log(qq, "error", f"Compose werd ongeldig, teruggedraaid:\n{err}") + jobs.finish(jid, "error"); jobs.done(qq); return + + _stack_meta_schrijven(d, {"connected": True}) + docker.compose_up(d, log_fn=lf, name=name) + jobs.log(qq, "ok", + f"Gekoppeld aan '{gedeeld_net}' \u2014 andere apps zijn nu " + "bereikbaar op hun containernaam.") + audit.log("stacks", "connect", "ok", ref=name, ip=request.remote_addr) + jobs.finish(jid, "done") + except Exception as e: + jobs.log(qq, "error", str(e)) + jobs.finish(jid, "error") + jobs.done(qq) + + jobs.run(_run, jid) + return jsonify(ok=True, job_id=jid) + + # ── Containers binnen een stack ────────────────────────────────────────────── def _stack_containers(name: str) -> list[str]: @@ -1313,6 +1511,11 @@ def api_store_install(): "connected": bool(verbinden and gedeeld_net), "categories": meta.get("categories") or [], "tags": meta.get("tags") or [], + # Zonder de gebruikte waarden valt het installatieformulier + # later niet opnieuw te openen om iets te wijzigen. + "format": "boilerplate" if is_bp else "compose", + "values": bp_values if is_bp else {}, + "env_overrides": {} if is_bp else env_overrides, }, ensure_ascii=False, indent=2), encoding="utf-8") except Exception: pass @@ -1984,7 +2187,24 @@ def api_mod_settings_put(mid): @app.route("/api/audit") def api_audit(): - return jsonify(entries=audit.query(200), total=audit.count()) + """Auditregels, optioneel gefilterd en doorbladerbaar.""" + a = request.args + filters = { + "src": (a.get("src") or "").strip(), + "action": (a.get("action") or "").strip(), + "status": (a.get("status") or "").strip(), + "zoek": (a.get("q") or "").strip()[:100], + } + dagen = a.get("days", "").strip() + if dagen.isdigit() and int(dagen) > 0: + filters["sinds"] = time.time() - int(dagen) * 86400 + limit = min(max(int(a.get("limit", 100)), 1), 500) + offset = max(int(a.get("offset", 0)), 0) + return jsonify(entries=audit.query(limit, offset, **filters), + total=audit.count(**filters), + grand_total=audit.count(), + facets=audit.facets(), + limit=limit, offset=offset) @app.route("/api/audit/clear", methods=["POST"]) diff --git a/server-up/core/audit.py b/server-up/core/audit.py index d428eb7..0a7da26 100644 --- a/server-up/core/audit.py +++ b/server-up/core/audit.py @@ -58,25 +58,67 @@ def log(src: str, action: str, status="ok", ref="", detail=None, ip=""): pass -def query(limit=100, offset=0) -> list[dict]: +def _filter(src="", action="", status="", zoek="", sinds=None): + """Bouw de WHERE-clausule op uit de opgegeven filters.""" + delen, params = [], [] + if src: + delen.append("src = ?"); params.append(src) + if action: + delen.append("action = ?"); params.append(action) + if status: + delen.append("status = ?"); params.append(status) + if sinds: + delen.append("ts >= ?"); params.append(float(sinds)) + if zoek: + delen.append("(ref LIKE ? OR detail LIKE ? OR ip LIKE ?)") + params += [f"%{zoek}%"] * 3 + return (" WHERE " + " AND ".join(delen)) if delen else "", params + + +def query(limit=100, offset=0, **filters) -> list[dict]: + """Regels uit het log, nieuwste eerst, optioneel gefilterd. + + Zonder filters groeide het log snel onbruikbaar: sinds er logins, + rolwijzigingen, backups en updates in landen zijn de laatste tweehonderd + regels al na een dag beheerwerk niet meer representatief. + """ + where, params = _filter(**filters) try: with _lock: rows = _connect().execute( - "SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?", - (limit, offset)).fetchall() + f"SELECT * FROM log{where} ORDER BY ts DESC LIMIT ? OFFSET ?", + (*params, limit, offset)).fetchall() return [dict(r) for r in rows] except Exception: return [] -def count() -> int: +def count(**filters) -> int: + where, params = _filter(**filters) try: with _lock: - return _connect().execute("SELECT count(*) FROM log").fetchone()[0] + return _connect().execute( + f"SELECT count(*) FROM log{where}", params).fetchone()[0] except Exception: return 0 +def facets() -> dict: + """Welke bronnen, acties en statussen komen voor β€” voor de filterkeuzes.""" + uit = {"src": [], "action": [], "status": []} + try: + with _lock: + c = _connect() + for kolom in uit: + rows = c.execute( + f"SELECT DISTINCT {kolom} FROM log " + f"WHERE {kolom} != '' ORDER BY {kolom}").fetchall() + uit[kolom] = [r[0] for r in rows] + except Exception: + pass + return uit + + def clear(): try: with _lock: diff --git a/server-up/core/compose_transform.py b/server-up/core/compose_transform.py index b60b2e3..8c3c56a 100644 --- a/server-up/core/compose_transform.py +++ b/server-up/core/compose_transform.py @@ -91,9 +91,17 @@ def add_shared_network(compose_text: str, network: str) -> str: if not isinstance(doc, dict) or not isinstance(doc.get("services"), dict): raise TransformError("compose bevat geen services") + aangesloten = 0 for svc in doc["services"].values(): if not isinstance(svc, dict): continue + # Een service met `network_mode` deelt al de namespace van de host of + # van een andere container. Docker compose weigert die combinatie: + # "declares mutually exclusive network_mode and networks". Dit raakt + # Homebridge en Scrypted (host, voor mDNS), de Beszel-agent, en de + # downloadclients van de ARR-stack achter Gluetun. + if svc.get("network_mode"): + continue # `networks:` mag een lijst of een mapping zijn; allebei aanvullen. huidig = svc.get("networks") if huidig is None: @@ -103,12 +111,17 @@ def add_shared_network(compose_text: str, network: str) -> str: huidig.append(network) elif isinstance(huidig, dict): huidig.setdefault(network, None) + aangesloten += 1 - netwerken = doc.get("networks") - if not isinstance(netwerken, dict): - netwerken = {} - netwerken.setdefault(network, {"external": True}) - doc["networks"] = netwerken + # Sluit geen enkele service aan, dan hoeft het netwerk ook niet gedeclareerd + # te worden β€” een ongebruikt extern netwerk laat compose alsnog struikelen + # als het niet bestaat. + if aangesloten: + netwerken = doc.get("networks") + if not isinstance(netwerken, dict): + netwerken = {} + netwerken.setdefault(network, {"external": True}) + doc["networks"] = netwerken return yaml.safe_dump(doc, sort_keys=False, default_flow_style=False, allow_unicode=True, width=120) diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 8f45653..f7102b2 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -356,6 +356,13 @@ tailwind.config = { + + @@ -531,7 +538,33 @@ tailwind.config = {
-
+
+
+ + +
+ + + + + +
+

Audit Log

@@ -1162,16 +1195,22 @@ tailwind.config = {