Initial commit: Server Up v0.3.0

This commit is contained in:
bes-r 2026-05-09 13:55:29 +00:00
commit 5580d5ec69
41 changed files with 4694 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
*.pyc
*.pyo
.env

65
CHANGELOG.md Normal file
View file

@ -0,0 +1,65 @@
# v0.3.0 — UI rebuild + Boilerplates support
## Hoogtepunten
### 🎨 Nieuwe UI (Tailwind + Alpine + HTMX)
- `templates/index.html` is volledig herschreven. De handgeschreven CSS
(`--bg/--s1/...` variabelen, ad-hoc grid-classes) is vervangen door
Tailwind utility-classes met een gematchte donker/licht-palette.
- Statebeheer via Alpine.js: één `app()` component bovenop het hele document,
geen `$=document.getElementById`-spaghetti meer.
- HTMX is geladen voor toekomstige server-rendered partials. Het bestaande
fetch-RPC patroon blijft werken; HTMX kan progressief worden ingezet.
- Modals, toasts, terminal-overlay en first-run wizard zitten allemaal in
één Alpine-state — geen losse globale variabelen meer.
- Mobiele sidebar gedraagt zich nu correct (slide-in i.p.v. layout-flip).
### 🧩 ChristianLempa Boilerplates ondersteund
Server Up herkent nu twee stack-formaten naast elkaar:
| Formaat | Detectie | Bron |
|---|---|---|
| **Compose** (origineel) | `compose.yml` / `docker-compose.yml` (+ optioneel `stack.json`) | bes-r/server-up |
| **Boilerplate** (nieuw) | `template.json` + `files/` | ChristianLempa/boilerplates-library |
Nieuw bestand `app/core/boilerplates.py`:
- `is_boilerplate(d)` — detectie
- `metadata(d)` — converteert `template.json["metadata"]` (incl. selfhst-icons) naar Server-Up formaat
- `fields(d)` — flattened variable-schema voor de install-modal
- `render_to_dir(src, dest, values)` — rendert `files/*.yaml` met de Jinja-achtige
`<< var >>` + `<%- if expr %>` syntax die de boilerplates gebruiken (Jinja2 met
custom delimiters). Niet-tekst bestanden worden verbatim gekopieerd.
`app/core/git.py``_scan_compose_dirs` herkent beide formaten en zet
`format: "boilerplate"` in de stack-entry zodat de UI er een badge bij kan tonen.
`app/app.py`:
- `_find_stack_src` accepteert ook boilerplate-mappen.
- `POST /api/store/preview` retourneert voor boilerplates het variabelen-schema
(`fields`) plus een gerenderde preview met defaults.
- `POST /api/store/install` met body `{values: {...}}` rendert de templates
voor je voordat de stack gestart wordt.
### ⚙️ Default-repos
Een nieuwe Server Up komt nu uit de doos met twee app-repositories:
1. `bes-r/server-up` — eigen stacks, submap `apps`
2. `ChristianLempa/boilerplates-library` — community templates, submap `compose`
### 📦 Versie / Dockerfile
- `SU_VERSION` = `0.3.0` in `Dockerfile` en `docker-compose.yml`
- `requirements.txt`: Jinja2 expliciet toegevoegd (was al een Flask-dep)
## Migratie vanaf v0.2.29
- Build opnieuw: `docker compose up -d --build --no-cache`
- Bestaande stacks blijven werken (legacy formaat is intact).
- De Boilerplates-repo wordt automatisch toegevoegd voor verse installs.
Bestaande gebruikers kunnen de repo handmatig toevoegen via
Instellingen → Git Repositories met submap `compose`.
## Bekende beperkingen
- Boilerplate-templates met onbekende custom-filters of complexe Ansible-style
conditionals kunnen falen — de fout verschijnt in het terminal-paneel.
- `volumes:` blokken die conditioneel zijn (`<%- if volume_mode == 'local' %>`)
werken; complexere render-logica (loops over services) is nog niet getest.
- HTMX is geladen maar de meeste interacties draaien nog op fetch-RPC. Verdere
migratie naar server-rendered partials kan stapsgewijs in volgende releases.

49
Dockerfile Normal file
View file

@ -0,0 +1,49 @@
FROM python:3.12-slim AS build
WORKDIR /app
COPY app/requirements.txt .
RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt
FROM python:3.12-slim
LABEL org.opencontainers.image.title="Server Up" org.opencontainers.image.version="0.3.0"
RUN apt-get update && apt-get install -y --no-install-recommends \
git openssh-client curl tar gzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install Docker CLI + docker-compose inside container
RUN DPKG_ARCH=$(dpkg --print-architecture) \
&& case "$DPKG_ARCH" in \
amd64) DOCKER_ARCH=x86_64; COMPOSE_ARCH=x86_64 ;; \
arm64) DOCKER_ARCH=aarch64; COMPOSE_ARCH=aarch64 ;; \
armhf) DOCKER_ARCH=armhf; COMPOSE_ARCH=armv7 ;; \
*) DOCKER_ARCH=$DPKG_ARCH; COMPOSE_ARCH=$DPKG_ARCH ;; \
esac \
&& curl -fsSL "https://download.docker.com/linux/static/stable/${DOCKER_ARCH}/docker-27.5.1.tgz" \
| tar xz --strip-components=1 -C /usr/local/bin docker/docker \
&& chmod +x /usr/local/bin/docker \
&& curl -fsSL "https://github.com/docker/compose/releases/download/v2.32.4/docker-compose-linux-${COMPOSE_ARCH}" \
-o /usr/local/bin/docker-compose \
&& chmod +x /usr/local/bin/docker-compose \
&& mkdir -p /usr/local/lib/docker/cli-plugins /usr/libexec/docker/cli-plugins \
&& ln -sf /usr/local/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose \
&& ln -sf /usr/local/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
COPY --from=build /inst /usr/local
WORKDIR /app
COPY app/ ./
COPY modules/ ./modules-bundled/
RUN mkdir -p static/fonts \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \
&& sed -i "s|https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/||g" static/fonts/mdi.min.css
# Directories + git safe.directory (fix dubious ownership)
RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
&& printf '[safe]\n\tdirectory = *\n' > /root/.gitconfig
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
SU_VERSION=0.3.0 \
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
GIT_SSH_COMMAND="ssh -F /dev/null -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://localhost:5000/api/docker/info || exit 1
CMD ["python","app.py"]

1343
app/app.py Normal file

File diff suppressed because it is too large Load diff

134
app/core/__init__.py Normal file
View file

@ -0,0 +1,134 @@
"""Configuration — JSON backed, atomic writes."""
import json, os, re, tempfile, threading
from pathlib import Path
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,
}
_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 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])
# 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
for k in DEFAULTS:
if k in os.environ:
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:
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

58
app/core/audit.py Normal file
View file

@ -0,0 +1,58 @@
"""Audit log backed by SQLite."""
import sqlite3, time, json, os, threading
from pathlib import Path
_db = Path(os.environ.get("SU_AUDIT", "/data/audit.db"))
_local = threading.local()
def init():
_db.parent.mkdir(parents=True, exist_ok=True)
with _conn() as c:
c.execute("""CREATE TABLE IF NOT EXISTS log(
id INTEGER PRIMARY KEY, ts REAL,
src TEXT, action TEXT, status TEXT,
ref TEXT, detail TEXT, ip TEXT)""")
def _conn():
if not hasattr(_local, "c") or _local.c is None:
_local.c = sqlite3.connect(str(_db), timeout=5)
_local.c.row_factory = sqlite3.Row
return _local.c
def log(src: str, action: str, status="ok", ref="", detail=None, ip=""):
d = json.dumps(detail) if isinstance(detail, (dict, list)) else str(detail or "")
try:
with _conn() as c:
c.execute("INSERT INTO log(ts,src,action,status,ref,detail,ip) VALUES(?,?,?,?,?,?,?)",
(time.time(), src, action, status, ref, d, ip))
except Exception:
pass
def query(limit=100, offset=0) -> list[dict]:
try:
with _conn() as c:
rows = c.execute("SELECT * FROM log ORDER BY ts DESC LIMIT ? OFFSET ?",
(limit, offset)).fetchall()
return [dict(r) for r in rows]
except Exception:
return []
def count() -> int:
try:
with _conn() as c:
return c.execute("SELECT count(*) FROM log").fetchone()[0]
except Exception:
return 0
def clear():
try:
with _conn() as c:
c.execute("DELETE FROM log")
except Exception:
pass

247
app/core/boilerplates.py Normal file
View file

@ -0,0 +1,247 @@
"""ChristianLempa Boilerplates compatibility layer.
Detects, parses and renders templates from the boilerplates-library format:
<stack>/
template.json (metadata + variable schema)
files/
compose.yaml (Jinja-like template with << var >> and <%- if %> syntax)
Renders boilerplate templates into a flat directory that Server Up can manage
just like any other stack i.e. a single compose file (+ optional .env).
"""
from __future__ import annotations
import json, re, shutil
from pathlib import Path
from typing import Any
# Jinja delimiters used by the boilerplates library
# Variable expressions use << >> and statement blocks use <% %>.
_VAR_OPEN, _VAR_CLOSE = "<<", ">>"
_BLK_OPEN, _BLK_CLOSE = "<%", "%>"
# ── Detection ────────────────────────────────────────────────────────────────
def is_boilerplate(d: Path) -> bool:
"""True if the directory follows the ChristianLempa boilerplate layout."""
if not d.is_dir():
return False
tj = d / "template.json"
fd = d / "files"
return tj.is_file() and fd.is_dir()
def read_template(d: Path) -> dict | None:
tj = d / "template.json"
if not tj.exists():
return None
try:
return json.loads(tj.read_text(encoding="utf-8"))
except Exception:
return None
def metadata(d: Path) -> dict:
"""Return Server-Up-style metadata extracted from template.json."""
t = read_template(d) or {}
md = t.get("metadata") or {}
icon = md.get("icon") or {}
icon_url = ""
if isinstance(icon, dict):
prov = icon.get("provider")
iid = icon.get("id")
if prov == "selfhst" and iid:
icon_url = f"https://cdn.jsdelivr.net/gh/selfhst/icons/png/{iid}.png"
elif prov == "dashboard-icons" and iid:
icon_url = f"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/{iid}.png"
elif iid and (iid.startswith("http://") or iid.startswith("https://")):
icon_url = iid
return {
"name": md.get("name") or d.name,
"description": md.get("description", ""),
"tags": md.get("tags", []) or [],
"logo_url": icon_url,
"version": (md.get("version") or {}).get("name", ""),
"kind": t.get("kind", "compose"),
"format": "boilerplate",
"draft": bool(md.get("draft")),
}
# ── Variable schema → Server Up form fields ──────────────────────────────────
def fields(d: Path) -> list[dict]:
"""Flatten template.json variables into a list of UI fields.
Each field has: name, type, title, group, default, required,
options (for enum), needs (visibility deps), placeholder, description.
"""
t = read_template(d) or {}
out: list[dict] = []
for grp in t.get("variables", []) or []:
gname = grp.get("title") or grp.get("name", "")
toggle = grp.get("toggle")
for item in grp.get("items", []) or []:
cfg = item.get("config") or {}
out.append({
"name": item.get("name", ""),
"type": item.get("type", "str"),
"title": item.get("title") or item.get("name", ""),
"group": gname,
"group_toggle": toggle,
"default": item.get("default"),
"required": bool(item.get("required")),
"options": cfg.get("options") or [],
"needs": item.get("needs") or [],
"placeholder": cfg.get("placeholder", ""),
"description": item.get("description", ""),
})
return out
# ── Templating engine ────────────────────────────────────────────────────────
class BoilerplateError(Exception):
pass
def _coerce(value: Any, typ: str):
if typ == "bool":
if isinstance(value, bool):
return value
s = str(value).strip().lower()
return s in ("1", "true", "yes", "on")
if typ == "int":
try:
return int(str(value).strip())
except (ValueError, TypeError):
return 0
return str(value) if value is not None else ""
def build_context(d: Path, values: dict[str, Any]) -> dict[str, Any]:
"""Merge user-provided values with defaults declared in template.json,
coercing each value to its declared type."""
ctx: dict[str, Any] = {}
for f in fields(d):
name = f["name"]
if name in values and values[name] not in (None, ""):
ctx[name] = _coerce(values[name], f["type"])
elif f.get("default") is not None:
ctx[name] = _coerce(f["default"], f["type"])
else:
# Sensible defaults so Jinja doesn't blow up on undefined vars
ctx[name] = "" if f["type"] != "bool" else False
# Allow extra values to pass through (e.g. derived service_name)
for k, v in values.items():
if k not in ctx and v is not None:
ctx[k] = v
return ctx
_env = None
def _jinja_env():
"""Create (once) and return the Jinja2 environment for the boilerplates delimiters."""
global _env
if _env is None:
try:
from jinja2 import Environment, ChainableUndefined
except ImportError as e:
raise BoilerplateError("Jinja2 is required to render boilerplate templates") from e
_env = Environment(
variable_start_string=_VAR_OPEN,
variable_end_string=_VAR_CLOSE,
block_start_string=_BLK_OPEN,
block_end_string=_BLK_CLOSE,
comment_start_string="<#",
comment_end_string="#>",
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
undefined=ChainableUndefined,
)
return _env
def render_text(template: str, ctx: dict[str, Any]) -> str:
env = _jinja_env()
try:
return env.from_string(template).render(**ctx)
except Exception as e:
raise BoilerplateError(f"render failed: {e}") from e
def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]:
"""Render every file under <src>/files/ into <dest>/, applying the template
engine to text-like files. Binary files are copied verbatim.
Returns the list of relative paths written.
"""
files_dir = src / "files"
if not files_dir.is_dir():
raise BoilerplateError(f"no files/ directory in {src}")
ctx = build_context(src, values)
dest.mkdir(parents=True, exist_ok=True)
written: list[str] = []
for p in sorted(files_dir.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(files_dir)
out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True)
if _looks_textual(p):
try:
txt = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
shutil.copy2(p, out)
written.append(str(rel))
continue
rendered = render_text(txt, ctx)
# Drop blocks of pure whitespace left over after conditionals strip
rendered = _tidy(rendered)
out.write_text(rendered, encoding="utf-8")
else:
shutil.copy2(p, out)
written.append(str(rel))
# Always ensure the default compose name exists; rename if needed
_normalize_compose_name(dest)
return written
# ── Helpers ──────────────────────────────────────────────────────────────────
_TEXT_EXTS = {".yml", ".yaml", ".env", ".conf", ".cfg", ".ini", ".json",
".toml", ".sh", ".md", ".txt", ".tmpl", ".tpl", ".j2", ""}
def _looks_textual(p: Path) -> bool:
if p.suffix.lower() in _TEXT_EXTS:
return True
# also accept dotfiles like ".env"
if p.name.startswith(".") and not p.suffix:
return True
try:
chunk = p.read_bytes()[:512]
chunk.decode("utf-8")
return b"\x00" not in chunk
except Exception:
return False
def _tidy(text: str) -> str:
# Collapse 3+ blank lines into 2 — block conditionals leave gaps behind.
return re.sub(r"\n{3,}", "\n\n", text)
def _normalize_compose_name(dest: Path):
"""Ensure there's a docker-compose.yml at root; rename compose.yaml if not."""
if (dest / "docker-compose.yml").exists() or (dest / "compose.yml").exists():
return
cy = dest / "compose.yaml"
if cy.exists():
# Some tooling expects docker-compose.yml; symlink-style rename.
cy.rename(dest / "docker-compose.yml")

2
app/core/config.py Normal file
View file

@ -0,0 +1,2 @@
"""Compatibility shim — re-export core config functions."""
from core import load, save, patch, DEFAULTS

264
app/core/docker.py Normal file
View file

@ -0,0 +1,264 @@
"""Docker CLI wrapper — no SDK needed."""
import json, os, re, subprocess, shutil, threading
from pathlib import Path
COMPOSE_NAMES = ("compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml")
def _bin() -> str:
for p in ("/usr/local/bin/docker", "/usr/bin/docker"):
if os.path.isfile(p):
return p
return shutil.which("docker") or "docker"
def _run(cmd, **kw) -> subprocess.CompletedProcess:
kw.setdefault("capture_output", True)
kw.setdefault("text", True)
kw.setdefault("timeout", 60)
try:
return subprocess.run(cmd, **kw)
except FileNotFoundError:
return subprocess.CompletedProcess(cmd, 127, "", "docker niet gevonden")
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, 1, "", "timeout")
def _compose_base() -> list[str]:
# Probeer docker compose (plugin)
r = _run([_bin(), "compose", "version"], timeout=5)
if r.returncode == 0:
return [_bin(), "compose"]
# Probeer standalone docker-compose
dc = shutil.which("docker-compose")
if dc:
return [dc]
# Probeer compose plugin op alternatieve locaties
for plugin_dir in ("/usr/lib/docker/cli-plugins",
"/usr/libexec/docker/cli-plugins",
"/usr/local/lib/docker/cli-plugins",
os.path.expanduser("~/.docker/cli-plugins")):
compose_bin = os.path.join(plugin_dir, "docker-compose")
if os.path.isfile(compose_bin):
return [compose_bin]
# Laatste poging: docker-compose uit PATH
return [_bin(), "compose"]
# Cache compose base command (thread-safe)
_compose_cmd = None
_compose_lock = threading.Lock()
def _get_compose() -> list[str]:
global _compose_cmd
if _compose_cmd is None:
with _compose_lock:
if _compose_cmd is None:
_compose_cmd = _compose_base()
return _compose_cmd
def _stream(cmd, cwd=None, log_fn=None) -> int:
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=cwd)
for line in p.stdout:
s = line.rstrip()
if s and log_fn:
log_fn(s)
return p.wait()
except Exception as e:
if log_fn:
log_fn(f"Fout: {e}")
return 1
# ── Info ──────────────────────────────────────────────────────────────────────
def info() -> dict:
r = _run([_bin(), "info", "--format", "json"], timeout=10)
if r.returncode == 0:
try:
d = json.loads(r.stdout)
return {"ok": True, "version": d.get("ServerVersion", "?"),
"containers": d.get("Containers", 0),
"running": d.get("ContainersRunning", 0),
"images": d.get("Images", 0)}
except Exception:
pass
# Fallback
ver = _run([_bin(), "version", "--format", "{{.Server.Version}}"], timeout=5)
ps = _run([_bin(), "ps", "-q"], timeout=5)
imgs = _run([_bin(), "images", "-q"], timeout=5)
return {
"ok": ver.returncode == 0,
"version": ver.stdout.strip() if ver.returncode == 0 else "?",
"running": len(ps.stdout.strip().splitlines()) if ps.returncode == 0 else 0,
"containers": 0,
"images": len(imgs.stdout.strip().splitlines()) if imgs.returncode == 0 else 0,
}
# ── Images ────────────────────────────────────────────────────────────────────
def images() -> list[dict]:
fmt = "{{.ID}}\t{{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}"
r = _run([_bin(), "images", "--format", fmt], timeout=15)
if r.returncode != 0:
return []
out = []
for line in r.stdout.strip().splitlines():
p = line.split("\t")
if len(p) >= 4:
out.append({"id": p[0], "repo": p[1], "tag": p[2],
"size": p[3], "age": p[4] if len(p) > 4 else ""})
return out
def rmi(image_id: str, force=False) -> tuple[bool, str]:
cmd = [_bin(), "rmi"]
if force:
cmd.append("-f")
cmd.append(image_id)
r = _run(cmd, timeout=30)
return r.returncode == 0, (r.stderr or r.stdout).strip()
def prune_images() -> tuple[bool, str]:
r = _run([_bin(), "image", "prune", "-af"], timeout=120)
return r.returncode == 0, (r.stdout or r.stderr).strip()
# ── Container management ─────────────────────────────────────────────────────
def restart_container(name: str) -> tuple[bool, str]:
r = _run([_bin(), "restart", name], timeout=30)
return r.returncode == 0, (r.stderr or r.stdout).strip()
# ── Compose helpers ───────────────────────────────────────────────────────────
def find_compose(d: Path) -> Path | None:
for n in COMPOSE_NAMES:
f = d / n
if f.exists():
return f
return None
def has_compose(d: Path) -> bool:
return find_compose(d) is not None
def compose_ps(d: Path, name=None) -> list[dict]:
f = find_compose(d)
if not f:
return []
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["ps", "--format", "json"]
r = _run(cmd, cwd=str(d), timeout=10)
if r.returncode != 0:
return []
out = []
for line in r.stdout.strip().splitlines():
try:
c = json.loads(line)
out.append({
"name": c.get("Name", ""),
"service": c.get("Service", ""),
"state": c.get("State", ""),
"status": c.get("Status", ""),
"ports": c.get("Ports", ""),
"running": c.get("State", "").lower() in ("running", "up"),
})
except Exception:
pass
return out
def compose_up(d: Path, log_fn=None, name=None) -> int:
f = find_compose(d)
if not f:
return 1
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["up", "-d", "--remove-orphans"]
return _stream(cmd, cwd=str(d), log_fn=log_fn)
def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int:
f = find_compose(d)
if not f:
return 1
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["down"]
if volumes:
cmd.append("-v")
return _stream(cmd, cwd=str(d), log_fn=log_fn)
def compose_pull(d: Path, log_fn=None) -> int:
f = find_compose(d)
if not f:
return 1
cmd = _get_compose() + ["-f", str(f), "pull"]
return _stream(cmd, cwd=str(d), log_fn=log_fn)
def compose_logs(d: Path, tail=80, name=None) -> str:
f = find_compose(d)
if not f:
return ""
cmd = _get_compose() + ["-f", str(f)]
if name:
cmd += ["-p", name]
cmd += ["logs", "--tail", str(tail), "--no-color"]
r = _run(cmd, cwd=str(d), timeout=15)
return r.stdout if r.returncode == 0 else r.stderr
def read_env(d: Path) -> str:
f = d / ".env"
return f.read_text() if f.exists() else ""
def write_env(d: Path, content: str):
f = d / ".env"
f.write_text(content)
def read_compose(d: Path) -> str:
f = find_compose(d)
return f.read_text() if f else ""
def write_compose(d: Path, content: str):
f = find_compose(d)
if f:
f.write_text(content)
def used_ports() -> set[int]:
r = _run([_bin(), "ps", "-a", "--format", "{{.Ports}}"], timeout=10)
ports = set()
if r.returncode == 0:
for m in re.findall(r"(?:0\.0\.0\.0|::):(\d+)->", r.stdout):
try:
ports.add(int(m))
except ValueError:
pass
return ports
def next_free_port(start=8100) -> int:
used = used_ports()
p = start
while p in used:
p += 1
return p

215
app/core/git.py Normal file
View file

@ -0,0 +1,215 @@
"""Git operations — clone, pull, scan for stacks and modules."""
import json, os, subprocess, shutil
from pathlib import Path
from core.docker import COMPOSE_NAMES
from core import boilerplates as bp
CACHE = Path(os.environ.get("SU_GIT_CACHE", "/data/git"))
# Fix git "dubious ownership" — container draait als root, cache kan door andere uid zijn
_git_safe_set = False
def _ensure_git_safe():
global _git_safe_set
if not _git_safe_set:
os.environ["GIT_CONFIG_COUNT"] = "1"
os.environ["GIT_CONFIG_KEY_0"] = "safe.directory"
os.environ["GIT_CONFIG_VALUE_0"] = "*"
_git_safe_set = True
# Run immediately at import
_ensure_git_safe()
def cache_dir(repo_id: str) -> Path:
return CACHE / repo_id
def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
_ensure_git_safe()
url = repo.get("url", "").strip()
if not url:
return False, "Geen URL"
branch = repo.get("branch", "main")
token = repo.get("token", "").strip()
rid = repo.get("id", "repo")
dest = cache_dir(rid)
clone_url = url
if token and url.startswith("https://"):
host = url.split("://", 1)[1]
clone_url = f"https://x-access-token:{token}@{host}"
env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0"
try:
if dest.exists() and (dest / ".git").exists():
if log_fn:
log_fn(f"Pull {rid}")
r = subprocess.run(["git", "pull", "--rebase", "--autostash"],
cwd=str(dest), capture_output=True, text=True,
env=env, timeout=60)
else:
if log_fn:
log_fn(f"Clone {url}")
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
shutil.rmtree(dest)
r = subprocess.run(["git", "clone", "--branch", branch, "--depth", "1",
clone_url, str(dest)],
capture_output=True, text=True, env=env, timeout=120)
output = (r.stdout + "\n" + r.stderr).strip()
# Verwijder token uit output voordat het gelogd of teruggegeven wordt
safe_output = output.replace(clone_url, url) if clone_url != url else output
for line in safe_output.splitlines():
if line.strip() and log_fn:
log_fn(line)
if r.returncode != 0:
return False, safe_output[:200]
return True, "OK"
except subprocess.TimeoutExpired:
return False, "Timeout"
except Exception as e:
return False, str(e)
def scan_stacks(repo_id: str, lib: Path, subdir: str = "") -> list[dict]:
base = cache_dir(repo_id)
if subdir:
base = base / subdir
if not base.exists():
return []
stacks = _scan_compose_dirs(base, lib)
# Fallback: als geen stacks gevonden en geen subdir opgegeven,
# zoek automatisch in bekende submappen
if not stacks and not subdir:
for fallback in ("apps", "stacks", "docker"):
fb = cache_dir(repo_id) / fallback
if fb.exists():
stacks = _scan_compose_dirs(fb, lib)
if stacks:
break
return stacks
def scan_dir(base: Path, lib: Path) -> list[dict]:
"""Scan een lokale map voor stacks/boilerplates zonder git (voor ingebouwde apps)."""
if not base.exists():
return []
return _scan_compose_dirs(base, lib)
def _scan_compose_dirs(base: Path, lib: Path) -> list[dict]:
"""Scan één directory voor compose stacks. Detecteert zowel het klassieke
Server Up formaat (compose.yml + stack.json) als het ChristianLempa
Boilerplates formaat (template.json + files/)."""
stacks = []
for d in sorted(base.iterdir()):
if not d.is_dir() or d.name.startswith((".", "_")):
continue
is_legacy = any((d / n).exists() for n in COMPOSE_NAMES)
is_bp = bp.is_boilerplate(d)
if not (is_legacy or is_bp):
continue
if is_bp:
meta = bp.metadata(d)
# boilerplate met draft=true wordt overgeslagen tenzij expliciet
if meta.get("draft"):
continue
else:
meta = _meta(d)
meta.setdefault("format", "compose")
installed = []
if lib.exists():
for ld in lib.iterdir():
if ld.is_dir() and (ld.name == d.name or ld.name.startswith(d.name + "-")):
installed.append(ld.name)
entry = {
"dir": d.name,
"name": meta.pop("name", d.name),
"path": str(d),
"instances": sorted(installed),
}
entry.update(meta)
stacks.append(entry)
return stacks
def scan_modules(repo_id: str, subdir: str = "") -> list[dict]:
base = cache_dir(repo_id)
if subdir:
base = base / subdir
if not base.exists():
return []
# Fallback: als base geen subdirectories heeft, probeer bekende submappen
if not any(d.is_dir() for d in base.iterdir()):
if not subdir:
for fb in ("modules",):
fb_path = cache_dir(repo_id) / fb
if fb_path.exists():
base = fb_path
break
mods = []
for d in sorted(base.iterdir()):
if not d.is_dir() or d.name.startswith((".", "_")):
continue
# Vereist module.json
mf = d / "module.json"
if not mf.exists():
continue
init = d / "__init__.py"
if not init.exists():
continue
# Valideer dat __init__.py daadwerkelijk een Module subclass bevat
try:
src = init.read_text()
if "Module" not in src or "class " not in src:
continue
except Exception:
continue
meta = {}
try:
meta = json.loads(mf.read_text())
except Exception:
pass
mid = meta.get("id", d.name)
# Skip core features
from core.modules import CORE
if mid in CORE:
continue
mods.append({
"id": mid,
"name": meta.get("name", d.name),
"icon": meta.get("icon", "🧩"),
"desc": meta.get("description", ""),
"version": meta.get("version", "1.0.0"),
"tags": meta.get("tags", []),
"author": meta.get("author", ""),
"repo_id": repo_id,
"_src": str(d),
})
return mods
def _meta(d: Path) -> dict:
for n in ("app.json", "stack.json", "meta.json"):
f = d / n
if f.exists():
try:
return json.loads(f.read_text())
except Exception:
pass
return {"description": "", "icon": "", "tags": [], "ports": []}

39
app/core/i18n.py Normal file
View file

@ -0,0 +1,39 @@
"""Internationalisation — built-in NL + EN."""
import json
from pathlib import Path
_DIR = Path(__file__).parent.parent / "translations"
_cache: dict[str, dict] = {}
_langs: dict[str, dict] = {}
def load():
_cache.clear()
_langs.clear()
if not _DIR.exists():
return
for f in sorted(_DIR.glob("*.json")):
try:
data = json.loads(f.read_text("utf-8"))
meta = data.get("_meta", {})
code = meta.get("code", f.stem)
_langs[code] = meta
_cache[code] = {k: v for k, v in data.items() if not k.startswith("_")}
except Exception:
pass
def available() -> list[dict]:
if not _langs:
load()
return [{"code": k, "name": v.get("name", k), "flag": v.get("flag", "")}
for k, v in _langs.items()]
def strings(lang: str) -> dict:
if not _cache:
load()
base = dict(_cache.get("en", {}))
if lang != "en" and lang in _cache:
base.update(_cache[lang])
return base

74
app/core/jobs.py Normal file
View file

@ -0,0 +1,74 @@
"""Background job runner with log streaming."""
import threading, time, uuid, queue
_jobs: dict[str, dict] = {}
_queues: dict[str, queue.Queue] = {}
_stream_locks: dict[str, threading.Lock] = {}
_lock = threading.Lock()
_TTL = 3600
def _cleanup():
"""Verwijder voltooide jobs ouder dan TTL uit geheugen."""
cutoff = time.time() - _TTL
with _lock:
stale = [jid for jid, j in _jobs.items()
if j["status"] != "running" and j["ts"] < cutoff]
for jid in stale:
_jobs.pop(jid, None)
_queues.pop(jid, None)
_stream_locks.pop(jid, None)
def create(tag: str) -> tuple[str, queue.Queue]:
_cleanup()
jid = uuid.uuid4().hex[:8]
q = queue.Queue()
with _lock:
_jobs[jid] = {"id": jid, "tag": tag, "status": "running",
"lines": [], "ts": time.time()}
_queues[jid] = q
_stream_locks[jid] = threading.Lock()
return jid, q
def get_queue(jid: str) -> queue.Queue | None:
return _queues.get(jid)
def log(q, level: str, text: str):
if q:
q.put({"level": level, "text": text})
def finish(jid: str, status="done"):
with _lock:
if jid in _jobs:
_jobs[jid]["status"] = status
def done(q):
if q:
q.put(None)
def stream(jid: str, offset=0) -> dict:
job = _jobs.get(jid)
if not job:
return {"lines": [], "status": "unknown"}
with _stream_locks.get(jid, threading.Lock()):
q = _queues.get(jid)
if q:
while True:
try:
item = q.get_nowait()
if item is None:
break
job["lines"].append(item)
except queue.Empty:
break
return {"lines": job["lines"][offset:], "status": job["status"]}
def run(fn, *args):
threading.Thread(target=fn, args=args, daemon=True).start()

161
app/core/modules.py Normal file
View file

@ -0,0 +1,161 @@
"""Module system — base class + discovery."""
from __future__ import annotations
import importlib.util, inspect, json, sys
from pathlib import Path
from flask import Blueprint
CORE = frozenset({
# Core feature IDs
"stacks", "app_store", "docker_images", "audit", "settings", "wizard",
"language", "i18n", "projects", "system_info", "updater", "git_browser",
"store", "dashboard", "docker", "core", "modules",
# Repo module IDs that are core features (not optional)
"audit_log", "audit-log",
"system-info", "system_info",
"git-browser", "git_browser",
})
class Module:
ID = ""
NAME = ""
ICON = "🧩"
DESC = ""
VER = "1.0.0"
# Old-style compat attributes
MODULE_ID = ""
MODULE_NAME = ""
MODULE_ICON = ""
MODULE_DESC = ""
def __init_subclass__(cls, **kw):
"""Sync old MODULE_* attrs to new ID/NAME/ICON/DESC."""
super().__init_subclass__(**kw)
# Old-style MODULE_ID → new ID (old takes priority if set)
if cls.__dict__.get("MODULE_ID"):
cls.ID = cls.MODULE_ID
if cls.__dict__.get("MODULE_NAME"):
cls.NAME = cls.MODULE_NAME
if cls.__dict__.get("MODULE_ICON"):
cls.ICON = cls.MODULE_ICON
if cls.__dict__.get("MODULE_DESC"):
cls.DESC = cls.MODULE_DESC
def blueprint(self) -> Blueprint | None:
return None
def pages(self) -> list[dict]:
return []
def on_load(self, app) -> None:
pass
def settings_html(self) -> str | None:
return None
def get_config(self, key=None, default=None):
"""Haal module-specifieke config op."""
import core as cfg
mc = cfg.load().get("MODULE_SETTINGS", {}).get(self.ID, {})
if key is None:
return mc
return mc.get(key, default)
def save_config(self, updates: dict):
"""Sla module-specifieke config op."""
import core as cfg
c = cfg.load()
ms = dict(c.get("MODULE_SETTINGS", {}))
mc = dict(ms.get(self.ID, {}))
mc.update(updates)
ms[self.ID] = mc
cfg.patch({"MODULE_SETTINGS": ms})
def info(self) -> dict:
return {"id": self.ID, "name": self.NAME, "icon": self.ICON,
"desc": self.DESC, "version": self.VER,
"pages": self.pages(), "core": self.ID in CORE}
def discover(dirs: list[Path]) -> list[tuple[str, type, Path]]:
found = []
seen = set()
for base in dirs:
if not base.exists():
continue
for d in sorted(base.iterdir()):
if not d.is_dir() or d.name.startswith("_"):
continue
if not (d / "__init__.py").exists():
continue
# Vereist module.json
if not (d / "module.json").exists():
continue
mid = d.name
if mid in seen:
continue
# Skip CORE modules (dir name check)
if mid in CORE:
continue
seen.add(mid)
meta = _meta(d)
# Skip CORE modules (meta ID check)
meta_id = meta.get("id", mid)
if meta_id in CORE:
continue
if meta.get("enabled") is False:
continue
cls = _load(mid, d)
if cls:
# Final check: skip if the class ID is CORE
try:
inst = cls()
if inst.ID in CORE:
continue
except Exception:
pass
found.append((meta.get("order", 50), mid, cls, d.resolve()))
found.sort(key=lambda x: x[0])
return [(m, c, p) for _, m, c, p in found]
def _meta(d: Path) -> dict:
f = d / "module.json"
if f.exists():
try:
return json.loads(f.read_text())
except Exception:
pass
return {}
def _load(mid: str, d: Path) -> type | None:
cls, _ = _load_detail(mid, d)
return cls
def _load_detail(mid: str, d: Path) -> tuple[type | None, str]:
"""Load module class, return (cls, error_msg)."""
try:
name = f"_mod_{mid}"
init_file = d / "__init__.py"
if not init_file.exists():
return None, f"__init__.py niet gevonden in {d}"
spec = importlib.util.spec_from_file_location(
name, str(init_file),
submodule_search_locations=[str(d)])
if not spec or not spec.loader:
return None, f"kon spec niet laden voor {init_file}"
pkg = importlib.util.module_from_spec(spec)
sys.modules[name] = pkg
spec.loader.exec_module(pkg)
for _, obj in inspect.getmembers(pkg, inspect.isclass):
if obj is not Module and issubclass(obj, Module) and obj.__module__ == name:
return obj, ""
# Toon welke classes er WEL zijn
classes = [n for n, o in inspect.getmembers(pkg, inspect.isclass) if o.__module__ == name]
return None, f"geen Module subclass gevonden. Classes: {classes or 'geen'}"
except Exception as e:
print(f"{mid}: {e}")
return None, f"{type(e).__name__}: {e}"

1
app/modules/__init__.py Normal file
View file

@ -0,0 +1 @@
# Modules package — bevat base.py compatibility shim

5
app/modules/base.py Normal file
View file

@ -0,0 +1,5 @@
"""Compatibility shim — oude modules importeren 'from modules.base import ModuleBase'.
Dit verwijst door naar de nieuwe core.modules.Module class."""
from core.modules import Module as ModuleBase
__all__ = ["ModuleBase"]

3
app/requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask>=3.0
pyyaml>=6.0
jinja2>=3.1

1026
app/templates/index.html Normal file

File diff suppressed because it is too large Load diff

92
app/translations/en.json Normal file
View file

@ -0,0 +1,92 @@
{
"_meta": {"code": "en", "name": "English", "flag": "🇬🇧"},
"dashboard": "Dashboard",
"stacks": "Stacks",
"app_store": "App Store",
"images": "Docker Images",
"audit": "Audit Log",
"settings": "Settings",
"modules": "Modules",
"overview": "Overview of your Docker environment",
"running": "Running",
"stopped": "Stopped",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"update": "Update",
"remove": "Remove",
"install": "Install",
"save": "Save",
"saved": "Saved",
"cancel": "Cancel",
"close": "Close",
"search": "Search",
"loading": "Loading…",
"no_results": "No results",
"refresh": "Refresh",
"add": "Add",
"delete": "Delete",
"edit": "Edit",
"confirm": "Confirm",
"prune": "Prune",
"pull": "Pull",
"clone": "Clone",
"sync": "Sync",
"logs": "Logs",
"backup": "Backup",
"env_editor": "Edit .env",
"compose_editor": "Edit compose",
"paths": "Paths",
"library_dir": "Library directory",
"data_dir": "Data directory",
"backup_dir": "Backup directory",
"theme": "Theme",
"language": "Language",
"git": "Git",
"docker": "Docker",
"no_stacks": "No stacks found",
"no_images": "No images found",
"no_repos": "No repositories",
"add_repo": "Add repository",
"repo_name": "Name",
"repo_url": "Git URL",
"repo_branch": "Branch",
"repo_subdir": "Subdirectory",
"install_stack": "Install stack",
"instance_name": "Instance name",
"instance_hint": "Change for multiple installations",
"remove_stack": "Remove stack",
"remove_stack_only": "Stop containers + volumes",
"remove_all": "Stop + remove all files",
"prune_images": "Remove all unused images?",
"confirm_remove_image": "Remove image?",
"force_remove": "Force removal?",
"container_restart": "Restart container",
"container_restart_hint": "Restart the Server Up container",
"container_restarting": "Container is restarting…",
"wizard": "Setup Wizard",
"wizard_open": "Open wizard",
"wizard_reset": "Run again",
"wizard_welcome": "Welcome to Server Up",
"wizard_welcome_sub": "This wizard guides you through first-time setup. All steps are optional.",
"wizard_paths": "Set paths",
"wizard_paths_sub": "Where should your Docker projects be stored?",
"wizard_git": "Git repositories",
"wizard_git_sub": "Synchronise your stacks and modules.",
"wizard_done": "All done!",
"wizard_done_sub": "Setup complete. Change anything later in Settings.",
"prev": "Previous",
"next": "Next",
"finish": "Finish",
"ready": "Ready",
"busy": "Busy",
"done": "Done",
"error": "Error",
"optional_modules": "Optional modules",
"module_repos": "Module repositories",
"installed": "Installed",
"available": "Available",
"synced": "Synced",
"not_cloned": "Not cloned",
"auto_saved": "Auto-saved"
}

92
app/translations/nl.json Normal file
View file

@ -0,0 +1,92 @@
{
"_meta": {"code": "nl", "name": "Nederlands", "flag": "🇳🇱"},
"dashboard": "Dashboard",
"stacks": "Stacks",
"app_store": "App Store",
"images": "Docker Images",
"audit": "Audit Log",
"settings": "Instellingen",
"modules": "Modules",
"overview": "Overzicht van je Docker-omgeving",
"running": "Actief",
"stopped": "Gestopt",
"start": "Start",
"stop": "Stop",
"restart": "Herstart",
"update": "Bijwerken",
"remove": "Verwijderen",
"install": "Installeren",
"save": "Opslaan",
"saved": "Opgeslagen",
"cancel": "Annuleren",
"close": "Sluiten",
"search": "Zoeken",
"loading": "Laden…",
"no_results": "Geen resultaten",
"refresh": "Vernieuwen",
"add": "Toevoegen",
"delete": "Verwijderen",
"edit": "Bewerken",
"confirm": "Bevestigen",
"prune": "Opschonen",
"pull": "Pull",
"clone": "Clone",
"sync": "Synchroniseren",
"logs": "Logs",
"backup": "Backup",
"env_editor": ".env bewerken",
"compose_editor": "Compose bewerken",
"paths": "Paden",
"library_dir": "Library map",
"data_dir": "Data map",
"backup_dir": "Backup map",
"theme": "Thema",
"language": "Taal",
"git": "Git",
"docker": "Docker",
"no_stacks": "Geen stacks gevonden",
"no_images": "Geen images gevonden",
"no_repos": "Geen repositories",
"add_repo": "Repository toevoegen",
"repo_name": "Naam",
"repo_url": "Git URL",
"repo_branch": "Branch",
"repo_subdir": "Submap",
"install_stack": "Stack installeren",
"instance_name": "Instantie naam",
"instance_hint": "Wijzig voor meerdere installaties",
"remove_stack": "Stack verwijderen",
"remove_stack_only": "Stop containers + volumes",
"remove_all": "Stop + verwijder alle bestanden",
"prune_images": "Alle ongebruikte images verwijderen?",
"confirm_remove_image": "Image verwijderen?",
"force_remove": "Forceer verwijdering?",
"container_restart": "Container herstarten",
"container_restart_hint": "Herstart de Server Up container",
"container_restarting": "Container wordt herstart…",
"wizard": "Setup Wizard",
"wizard_open": "Wizard openen",
"wizard_reset": "Opnieuw uitvoeren",
"wizard_welcome": "Welkom bij Server Up",
"wizard_welcome_sub": "Deze wizard begeleidt je door de eerste opstart. Alle stappen zijn optioneel.",
"wizard_paths": "Mappen instellen",
"wizard_paths_sub": "Waar worden je Docker-projecten opgeslagen?",
"wizard_git": "Git repositories",
"wizard_git_sub": "Synchroniseer je stacks en modules.",
"wizard_done": "Klaar!",
"wizard_done_sub": "Setup is voltooid. Wijzig alles later via Instellingen.",
"prev": "Vorige",
"next": "Volgende",
"finish": "Afronden",
"ready": "Gereed",
"busy": "Bezig",
"done": "Klaar",
"error": "Fout",
"optional_modules": "Optionele modules",
"module_repos": "Module repositories",
"installed": "Geïnstalleerd",
"available": "Beschikbaar",
"synced": "Gesynchroniseerd",
"not_cloned": "Niet gecloned",
"auto_saved": "Automatisch opgeslagen"
}

View file

@ -0,0 +1,12 @@
services:
<< service_name >>:
image: adguard/adguardhome:latest
container_name: << service_name >>
ports:
- "<< port_web >>:3000"
- "<< port_dns >>:53/tcp"
- "<< port_dns >>:53/udp"
volumes:
- << data_dir >>/<< service_name >>/work:/opt/adguardhome/work
- << data_dir >>/<< service_name >>/conf:/opt/adguardhome/conf
restart: unless-stopped

View file

@ -0,0 +1,52 @@
{
"kind": "compose",
"metadata": {
"name": "AdGuard Home",
"description": "DNS-level advertentie- en trackerblokkering voor het hele netwerk",
"tags": ["dns", "adblock", "netwerk", "privacy"],
"icon": {"provider": "selfhst", "id": "adguard-home"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "adguard-home",
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true
}
]
},
{
"title": "Poorten",
"items": [
{
"name": "port_web",
"type": "int",
"title": "Web poort",
"default": 3000,
"required": true,
"description": "Setup-wizard en webinterface"
},
{
"name": "port_dns",
"type": "int",
"title": "DNS poort",
"default": 53,
"required": true,
"description": "Standaard DNS poort. Zorg dat poort 53 vrij is (disable systemd-resolved indien nodig)"
}
]
}
]
}

View file

@ -0,0 +1,17 @@
services:
<< service_name >>:
image: codeberg.org/forgejo/forgejo:latest
container_name: << service_name >>
ports:
- "<< port_web >>:3000"
- "<< port_ssh >>:22"
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__server__SSH_PORT=<< port_ssh >>
- TZ=<< timezone >>
volumes:
- << data_dir >>/<< service_name >>/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
restart: unless-stopped

View file

@ -0,0 +1,58 @@
{
"kind": "compose",
"metadata": {
"name": "Forgejo",
"description": "Lichtgewicht zelfgehoste Git-server. Actief onderhouden community-fork van Gitea.",
"tags": ["git", "code", "versiebeheer", "ontwikkeling"],
"icon": {"provider": "selfhst", "id": "forgejo"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "forgejo",
"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
}
]
},
{
"title": "Poorten",
"items": [
{
"name": "port_web",
"type": "int",
"title": "Web poort",
"default": 3000,
"required": true
},
{
"name": "port_ssh",
"type": "int",
"title": "SSH poort",
"default": 2222,
"required": true,
"description": "SSH-toegang voor git push/pull. Poort 2222 om conflict met host-SSH te vermijden."
}
]
}
]
}

View file

@ -0,0 +1,46 @@
services:
<< service_name >>:
image: ghcr.io/immich-app/immich-server:release
container_name: << service_name >>
ports:
- "<< port >>:2283"
environment:
- DB_HOSTNAME=<< service_name >>-postgres
- DB_USERNAME=immich
- DB_PASSWORD=<< db_password >>
- DB_DATABASE_NAME=immich
- REDIS_HOSTNAME=<< service_name >>-redis
- TZ=<< timezone >>
volumes:
- << data_dir >>/<< service_name >>/upload:/usr/src/app/upload
depends_on:
- << service_name >>-redis
- << service_name >>-postgres
restart: unless-stopped
<< service_name >>-machine-learning:
image: ghcr.io/immich-app/immich-machine-learning:release
container_name: << service_name >>-machine-learning
volumes:
- << service_name >>_model_cache:/cache
restart: unless-stopped
<< service_name >>-redis:
image: redis:6.2-alpine
container_name: << service_name >>-redis
restart: unless-stopped
<< service_name >>-postgres:
image: tensorchord/pgvecto-rs:pg14-v0.2.0
container_name: << service_name >>-postgres
environment:
- POSTGRES_USER=immich
- POSTGRES_PASSWORD=<< db_password >>
- POSTGRES_DB=immich
volumes:
- << service_name >>_pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
<< service_name >>_pgdata:
<< service_name >>_model_cache:

60
apps/immich/template.json Normal file
View file

@ -0,0 +1,60 @@
{
"kind": "compose",
"metadata": {
"name": "Immich",
"description": "Zelfgehoste foto- en videobeheer met automatische back-up. Google Photos alternatief.",
"tags": ["fotos", "media", "backup"],
"icon": {"provider": "selfhst", "id": "immich"},
"version": {"name": "release"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "immich",
"required": true
},
{
"name": "port",
"type": "int",
"title": "Web poort",
"default": 2283,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Upload directory",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Basismap voor foto-uploads en database"
},
{
"name": "timezone",
"type": "str",
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
}
]
},
{
"title": "Database",
"items": [
{
"name": "db_password",
"type": "str",
"title": "Database wachtwoord",
"default": "immich_db_pass",
"required": true,
"description": "Wachtwoord voor de interne PostgreSQL database",
"config": {"placeholder": "sterk-wachtwoord"}
}
]
}
]
}

View file

@ -0,0 +1,13 @@
services:
<< service_name >>:
image: jellyfin/jellyfin:latest
container_name: << service_name >>
ports:
- "<< port >>:8096"
environment:
- TZ=<< timezone >>
volumes:
- << data_dir >>/<< service_name >>/config:/config
- << data_dir >>/<< service_name >>/cache:/cache
- << media_dir >>:/media:ro
restart: unless-stopped

View file

@ -0,0 +1,59 @@
{
"kind": "compose",
"metadata": {
"name": "Jellyfin",
"description": "Gratis en open-source mediaserver voor films, series en muziek. Geen abonnement.",
"tags": ["media", "streaming", "films", "muziek"],
"icon": {"provider": "selfhst", "id": "jellyfin"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "jellyfin",
"required": true
},
{
"name": "port",
"type": "int",
"title": "Web poort",
"default": 8096,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Basismap voor configuratie en cache"
},
{
"name": "timezone",
"type": "str",
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
}
]
},
{
"title": "Media",
"items": [
{
"name": "media_dir",
"type": "str",
"title": "Media directory",
"default": "/mnt/media",
"required": true,
"description": "Map met je films, series en muziek (wordt read-only gemount)"
}
]
}
]
}

View file

@ -0,0 +1,17 @@
services:
<< service_name >>:
image: n8nio/n8n:latest
container_name: << service_name >>
ports:
- "<< port >>:5678"
environment:
- N8N_HOST=<< webhook_host >>
- N8N_PORT=5678
- N8N_PROTOCOL=http
- WEBHOOK_URL=http://<< webhook_host >>:<< port >>/
- GENERIC_TIMEZONE=<< timezone >>
- TZ=<< timezone >>
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
volumes:
- << data_dir >>/<< service_name >>/data:/home/node/.n8n
restart: unless-stopped

59
apps/n8n/template.json Normal file
View file

@ -0,0 +1,59 @@
{
"kind": "compose",
"metadata": {
"name": "n8n",
"description": "No-code/low-code workflow automatisering. Zapier-alternatief met 400+ integraties.",
"tags": ["automatisering", "workflow", "integratie"],
"icon": {"provider": "selfhst", "id": "n8n"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "n8n",
"required": true
},
{
"name": "port",
"type": "int",
"title": "Web poort",
"default": 5678,
"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
}
]
},
{
"title": "Netwerk",
"items": [
{
"name": "webhook_host",
"type": "str",
"title": "Webhook hostname",
"default": "localhost",
"required": false,
"description": "Hostname of IP van je server voor webhook-URLs. Gebruik je domeinnaam als je een reverse proxy hebt.",
"config": {"placeholder": "mijn-server.nl"}
}
]
}
]
}

View file

@ -0,0 +1,20 @@
services:
<< service_name >>:
image: nextcloud/all-in-one:latest
init: true
container_name: << service_name >>
ports:
- "<< port_admin >>:8080"
environment:
- APACHE_PORT=<< port_nextcloud >>
- APACHE_IP_BINDING=0.0.0.0
- NEXTCLOUD_DATADIR=<< data_dir >>/<< service_name >>/ncdata
- TZ=<< timezone >>
volumes:
- << service_name >>_config:/mnt/docker-aio-config
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
volumes:
<< service_name >>_config:
name: << service_name >>_config

View file

@ -0,0 +1,61 @@
{
"kind": "compose",
"metadata": {
"name": "Nextcloud AIO",
"description": "Nextcloud All-in-One: één container beheert automatisch alle Nextcloud-componenten.",
"tags": ["bestanden", "cloud", "productiviteit", "samenwerken"],
"icon": {"provider": "selfhst", "id": "nextcloud"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "nextcloud-aio-mastercontainer",
"required": true,
"description": "AIO beheert zelf extra containers. Naam is normaal nextcloud-aio-mastercontainer."
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Basismap voor Nextcloud-bestanden"
},
{
"name": "timezone",
"type": "str",
"title": "Tijdzone",
"default": "Europe/Amsterdam",
"required": true
}
]
},
{
"title": "Poorten",
"items": [
{
"name": "port_admin",
"type": "int",
"title": "Admin poort (HTTPS)",
"default": 8080,
"required": true,
"description": "AIO beheerpaneel via https://server-ip:8080 — passphrase staat in de logs"
},
{
"name": "port_nextcloud",
"type": "int",
"title": "Nextcloud poort",
"default": 11000,
"required": true,
"description": "Nextcloud HTTP-poort (zet een reverse proxy voor HTTPS)"
}
]
}
]
}

View file

@ -0,0 +1,12 @@
services:
<< service_name >>:
image: jc21/nginx-proxy-manager:latest
container_name: << service_name >>
ports:
- "<< port_http >>:80"
- "<< port_https >>:443"
- "<< port_admin >>:81"
volumes:
- << data_dir >>/<< service_name >>/data:/data
- << data_dir >>/<< service_name >>/letsencrypt:/etc/letsencrypt
restart: unless-stopped

View file

@ -0,0 +1,60 @@
{
"kind": "compose",
"metadata": {
"name": "Nginx Proxy Manager",
"description": "Reverse proxy met automatische SSL-certificaten en eenvoudige webinterface",
"tags": ["proxy", "ssl", "netwerk"],
"icon": {"provider": "selfhst", "id": "nginx-proxy-manager"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "nginx-proxy-manager",
"required": true,
"description": "Naam van de Docker container"
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true,
"description": "Basismap voor persistente data"
}
]
},
{
"title": "Poorten",
"items": [
{
"name": "port_http",
"type": "int",
"title": "HTTP poort",
"default": 80,
"required": true
},
{
"name": "port_https",
"type": "int",
"title": "HTTPS poort",
"default": 443,
"required": true
},
{
"name": "port_admin",
"type": "int",
"title": "Admin poort",
"default": 81,
"required": true,
"description": "Beheerpaneel — standaard login: admin@example.com / changeme"
}
]
}
]
}

View file

@ -0,0 +1,41 @@
services:
<< service_name >>-broker:
image: redis:7-alpine
container_name: << service_name >>-broker
restart: unless-stopped
<< service_name >>-db:
image: postgres:16-alpine
container_name: << service_name >>-db
environment:
- POSTGRES_DB=paperless
- POSTGRES_USER=paperless
- POSTGRES_PASSWORD=<< db_password >>
volumes:
- << service_name >>_dbdata:/var/lib/postgresql/data
restart: unless-stopped
<< service_name >>:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
container_name: << service_name >>
ports:
- "<< port >>:8000"
environment:
- PAPERLESS_REDIS=redis://<< service_name >>-broker:6379
- PAPERLESS_DBHOST=<< service_name >>-db
- PAPERLESS_DBPASS=<< db_password >>
- PAPERLESS_SECRET_KEY=<< secret_key >>
- PAPERLESS_TIME_ZONE=<< timezone >>
- PAPERLESS_OCR_LANGUAGE=<< ocr_language >>
volumes:
- << data_dir >>/<< service_name >>/data:/usr/src/paperless/data
- << data_dir >>/<< service_name >>/media:/usr/src/paperless/media
- << data_dir >>/<< service_name >>/export:/usr/src/paperless/export
- << data_dir >>/<< service_name >>/consume:/usr/src/paperless/consume
depends_on:
- << service_name >>-broker
- << service_name >>-db
restart: unless-stopped
volumes:
<< service_name >>_dbdata:

View file

@ -0,0 +1,80 @@
{
"kind": "compose",
"metadata": {
"name": "Paperless-ngx",
"description": "Digitaal documentbeheer met OCR, slimme tags en zoekfunctie.",
"tags": ["documenten", "ocr", "archief", "productiviteit"],
"icon": {"provider": "selfhst", "id": "paperless-ngx"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "paperless-ngx",
"required": true
},
{
"name": "port",
"type": "int",
"title": "Web poort",
"default": 8010,
"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
}
]
},
{
"title": "Beveiliging",
"items": [
{
"name": "secret_key",
"type": "str",
"title": "Secret key",
"default": "",
"required": true,
"description": "Willekeurige lange string voor sessiebeveiliging",
"config": {"placeholder": "minimaal-50-willekeurige-tekens"}
},
{
"name": "db_password",
"type": "str",
"title": "Database wachtwoord",
"default": "paperless_db_pass",
"required": true,
"config": {"placeholder": "sterk-wachtwoord"}
}
]
},
{
"title": "OCR",
"items": [
{
"name": "ocr_language",
"type": "str",
"title": "OCR taal",
"default": "nld+eng",
"required": false,
"description": "Tesseract taalcodes, bijv. nld+eng voor Nederlands en Engels"
}
]
}
]
}

View file

@ -0,0 +1,9 @@
services:
<< service_name >>:
image: louislam/uptime-kuma:1
container_name: << service_name >>
ports:
- "<< port >>:3001"
volumes:
- << data_dir >>/<< service_name >>/data:/app/data
restart: unless-stopped

View file

@ -0,0 +1,38 @@
{
"kind": "compose",
"metadata": {
"name": "Uptime Kuma",
"description": "Mooie en eenvoudige uptime-monitor voor al je services en websites.",
"tags": ["monitoring", "uptime", "alerting"],
"icon": {"provider": "selfhst", "id": "uptime-kuma"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "uptime-kuma",
"required": true
},
{
"name": "port",
"type": "int",
"title": "Web poort",
"default": 3001,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true
}
]
}
]
}

View file

@ -0,0 +1,15 @@
services:
<< service_name >>:
image: vaultwarden/server:latest
container_name: << service_name >>
ports:
- "<< port >>:80"
environment:
- WEBSOCKET_ENABLED=true
- SIGNUPS_ALLOWED=<< signups_allowed | lower >>
<% if admin_token %>
- ADMIN_TOKEN=<< admin_token >>
<% endif %>
volumes:
- << data_dir >>/<< service_name >>/data:/data
restart: unless-stopped

View file

@ -0,0 +1,59 @@
{
"kind": "compose",
"metadata": {
"name": "Vaultwarden",
"description": "Lichtgewicht Bitwarden-compatibele wachtwoordkluis. Werkt met alle Bitwarden-apps.",
"tags": ["wachtwoorden", "beveiliging", "privacy"],
"icon": {"provider": "selfhst", "id": "vaultwarden"},
"version": {"name": "latest"}
},
"variables": [
{
"title": "Algemeen",
"items": [
{
"name": "service_name",
"type": "str",
"title": "Servicenaam",
"default": "vaultwarden",
"required": true
},
{
"name": "port",
"type": "int",
"title": "Poort",
"default": 8222,
"required": true
},
{
"name": "data_dir",
"type": "str",
"title": "Data directory",
"default": "/opt/serverup/appdata",
"required": true
}
]
},
{
"title": "Instellingen",
"items": [
{
"name": "signups_allowed",
"type": "bool",
"title": "Registratie toestaan",
"default": true,
"description": "Schakel uit na aanmaken van je account voor extra beveiliging"
},
{
"name": "admin_token",
"type": "str",
"title": "Admin token",
"default": "",
"required": false,
"description": "Willekeurige geheime string voor het /admin paneel. Laat leeg om admin uit te schakelen.",
"config": {"placeholder": "willekeurige-geheime-string"}
}
]
}
]
}

32
docker-compose.yml Normal file
View file

@ -0,0 +1,32 @@
services:
server-up:
build: .
image: server-up:latest
container_name: server-up
hostname: server-up
user: "0:0"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- su-data:/data
# Same-path mount: pad binnen container = pad op host
# Wijzig BASE_DIR in .env om alle paden te verplaatsen
- ${BASE_DIR:-/opt/serverup}:${BASE_DIR:-/opt/serverup}
ports:
- "${PORT:-5000}:5000"
environment:
- PORT=5000
- HOME=/root
- SU_CONFIG=/data/config.json
- SU_AUDIT=/data/audit.db
- SU_GIT_CACHE=/data/git
- SU_CONTAINER=server-up
# Voeg eigen app-repos toe zonder wizard. Eenmalig opgeslagen in config.json.
# Format: JSON-array van repo-objecten. Velden: id, url, branch, subdir, name, token (optioneel).
# Voorbeeld:
# - SU_BOOT_REPOS=[{"id":"mijn-apps","name":"Mijn Apps","url":"https://github.com/gebruiker/mijn-apps.git","branch":"main","subdir":"apps"}]
# Debug-endpoints inschakelen (api/debug/*):
# - SU_DEBUG=1
restart: unless-stopped
volumes:
su-data:

0
modules/__init__.py Normal file
View file