Initial commit: Server Up v0.3.0
This commit is contained in:
commit
5580d5ec69
41 changed files with 4694 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
65
CHANGELOG.md
Normal file
65
CHANGELOG.md
Normal 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
49
Dockerfile
Normal 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
1343
app/app.py
Normal file
File diff suppressed because it is too large
Load diff
134
app/core/__init__.py
Normal file
134
app/core/__init__.py
Normal 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
58
app/core/audit.py
Normal 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
247
app/core/boilerplates.py
Normal 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
2
app/core/config.py
Normal 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
264
app/core/docker.py
Normal 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
215
app/core/git.py
Normal 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
39
app/core/i18n.py
Normal 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
74
app/core/jobs.py
Normal 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
161
app/core/modules.py
Normal 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
1
app/modules/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Modules package — bevat base.py compatibility shim
|
||||
5
app/modules/base.py
Normal file
5
app/modules/base.py
Normal 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
3
app/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask>=3.0
|
||||
pyyaml>=6.0
|
||||
jinja2>=3.1
|
||||
1026
app/templates/index.html
Normal file
1026
app/templates/index.html
Normal file
File diff suppressed because it is too large
Load diff
92
app/translations/en.json
Normal file
92
app/translations/en.json
Normal 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
92
app/translations/nl.json
Normal 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"
|
||||
}
|
||||
12
apps/adguard-home/files/compose.yaml
Normal file
12
apps/adguard-home/files/compose.yaml
Normal 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
|
||||
52
apps/adguard-home/template.json
Normal file
52
apps/adguard-home/template.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
apps/forgejo/files/compose.yaml
Normal file
17
apps/forgejo/files/compose.yaml
Normal 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
|
||||
58
apps/forgejo/template.json
Normal file
58
apps/forgejo/template.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
46
apps/immich/files/compose.yaml
Normal file
46
apps/immich/files/compose.yaml
Normal 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
60
apps/immich/template.json
Normal 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"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
13
apps/jellyfin/files/compose.yaml
Normal file
13
apps/jellyfin/files/compose.yaml
Normal 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
|
||||
59
apps/jellyfin/template.json
Normal file
59
apps/jellyfin/template.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
apps/n8n/files/compose.yaml
Normal file
17
apps/n8n/files/compose.yaml
Normal 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
59
apps/n8n/template.json
Normal 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"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
20
apps/nextcloud-aio/files/compose.yaml
Normal file
20
apps/nextcloud-aio/files/compose.yaml
Normal 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
|
||||
61
apps/nextcloud-aio/template.json
Normal file
61
apps/nextcloud-aio/template.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
12
apps/nginx-proxy-manager/files/compose.yaml
Normal file
12
apps/nginx-proxy-manager/files/compose.yaml
Normal 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
|
||||
60
apps/nginx-proxy-manager/template.json
Normal file
60
apps/nginx-proxy-manager/template.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
41
apps/paperless-ngx/files/compose.yaml
Normal file
41
apps/paperless-ngx/files/compose.yaml
Normal 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:
|
||||
80
apps/paperless-ngx/template.json
Normal file
80
apps/paperless-ngx/template.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
9
apps/uptime-kuma/files/compose.yaml
Normal file
9
apps/uptime-kuma/files/compose.yaml
Normal 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
|
||||
38
apps/uptime-kuma/template.json
Normal file
38
apps/uptime-kuma/template.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
15
apps/vaultwarden/files/compose.yaml
Normal file
15
apps/vaultwarden/files/compose.yaml
Normal 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
|
||||
59
apps/vaultwarden/template.json
Normal file
59
apps/vaultwarden/template.json
Normal 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
32
docker-compose.yml
Normal 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
0
modules/__init__.py
Normal file
Loading…
Reference in a new issue