diff --git a/panel.nix b/panel.nix index c104b27..68e0e96 100644 --- a/panel.nix +++ b/panel.nix @@ -1,8 +1,5 @@ -{ config, lib, pkgs, ... }: +{ config, pkgs, ... }: -let - forgejoServer = config.services.forgejo.settings.server; -in { environment.systemPackages = [ (pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh)) @@ -36,8 +33,6 @@ in pkgs.zip pkgs.unzip pkgs.git - pkgs.util-linux # flock, used by panelctl to serialise routes file writes - pkgs.openssh # cloning repositories over ssh with the panel's deploy key ]; serviceConfig = { @@ -56,12 +51,6 @@ in PANEL_BASE_DIR = "/var/lib/containers"; PANELCTL_PATH = "/run/current-system/sw/bin/panelctl"; PANEL_FRONTEND_DIR = "${./panel/frontend}"; - - # Forgejo integration (repo picker, private clones, commit links). - # The API is reached on localhost; clones use the public URLs. - PANEL_FORGEJO_URL = lib.removeSuffix "/" forgejoServer.ROOT_URL; - PANEL_FORGEJO_API_URL = "http://${forgejoServer.HTTP_ADDR}:${toString forgejoServer.HTTP_PORT}"; - PANEL_FORGEJO_SSH_URL = "ssh://${forgejoServer.BUILTIN_SSH_SERVER_USER}@${forgejoServer.DOMAIN}:${toString forgejoServer.SSH_PORT}"; }; }; diff --git a/panel/API.md b/panel/API.md index 298faea..256cef6 100644 --- a/panel/API.md +++ b/panel/API.md @@ -12,11 +12,6 @@ Default bind: `127.0.0.1:9911` |--------|------|-------------| | GET | `/` | Web UI (served from `frontend/index.html`) | | GET | `/health` | Health check | -| GET | `/status` | All apps with routes, container status and running operation (what the UI polls) | -| GET | `/integrations` | Forgejo connection (`configured`, `url`, `has_token`, `user`) and the SSH deploy public key | -| POST | `/integrations/forgejo` | `{"token": "..."}` — verify against Forgejo and store; `""` disconnects | -| GET | `/forgejo/repos?q=` | Search repositories visible to the stored token (public ones without) | -| GET | `/forgejo/branches?repo=owner/name` | Branch names of a Forgejo repository | ### Apps — Read @@ -30,14 +25,6 @@ Default bind: `127.0.0.1:9911` | GET | `/apps//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | -| GET | `/apps//env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` | -| GET | `/apps//repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) | -| GET | `/apps//repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` | -| GET | `/apps//volumes` | Volumes the file browser can open | -| GET | `/apps//volume/files?vol=&path=` | List a folder in a volume | -| GET | `/apps//volume/download?vol=&path=` | Download a file from a volume | -| PUT | `/apps//volume/files?vol=&path=` | Upload a file (raw body) | -| DELETE | `/apps//volume/files?vol=&path=` | Delete a file or folder | ### Apps — Write @@ -54,79 +41,6 @@ Default bind: `127.0.0.1:9911` | POST | `/apps//backup` | Create volume backup (zip) | | POST | `/apps//restore` | Restore from backup | | POST | `/apps//remove` | Remove app | -| POST | `/apps//repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy | -| POST | `/apps//env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` | -| POST | `/apps//volume-clear` | Stop the app and empty its default data folder | - -Write operations are serialised per app. While one runs, another write to the -same app returns `409` with `{"ok": false, "error": "...", "busy": "deploy"}`. -`deploy` returns the compose output in `stdout` (or `stderr` on failure). - -### Create app (git repository, with environment variables) - -```json -{ - "name": "blog", - "routes": [{"domain": "blog.srazka.com", "upstream": "127.0.0.1:18090"}], - "auth": true, - "source_type": "git", - "repo_url": "https://git.srazka.com/reudy-net/blog.git", - "repo_branch": "", - "use_forgejo_token": true, - "env": [{"key": "DATABASE_URL", "value": "postgres://..."}], - "env_inject": true -} -``` - -- `repo_url` may be `https://…`, `ssh://git@host:port/owner/repo.git` or - `git@host:owner/repo.git`. ssh URLs use the panel's deploy key. -- `repo_token` sets an https token explicitly; `use_forgejo_token` uses the - token stored in Settings (only for URLs on the configured Forgejo host). -- An empty branch uses the repository's default branch. The compose file must - be at the repository root. -- The older `source_type: "github"` with `github_url` / `github_branch` / - `github_pat` is still accepted. - -### Sync response (`repo-pull`) - -```json -{ - "ok": true, - "stdout": "HEAD is now at d7df557 Bump image tag\n...compose output...", - "before": {"sha": "4fb7976...", "short": "4fb7976", "subject": "Initial compose", "author": "reudy", "time": 1790460618}, - "after": {"sha": "d7df557...", "short": "d7df557", "subject": "Bump image tag", "author": "reudy", "time": 1790460643}, - "changed": true -} -``` - -### Status response (`/status`) - -```json -{ - "ok": true, - "time": 1790460650, - "apps": [ - { - "name": "whoami", - "routes": [{"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"}], - "auth": true, - "compose_file": "/var/lib/containers/stacks/whoami/compose.yaml", - "repo_url": "", - "repo_branch": "", - "busy": null, - "status": { - "state": "running", - "running": true, - "running_count": 1, - "total": 1, - "containers": [{"name": "whoami-app-1", "state": "running", "status": "Up 3 minutes", "image": "docker.io/traefik/whoami:latest", "running": true}] - } - } - ] -} -``` - -`state` is one of `running`, `partial` (some containers down), `stopped` or `unknown`. ## Example payloads diff --git a/panel/README.md b/panel/README.md index 5f9cc49..02ede16 100644 --- a/panel/README.md +++ b/panel/README.md @@ -22,19 +22,14 @@ All app routes are written to a single `routes/routes.caddy` file that Caddy imp ## Quick workflow ```bash -# Routes are "domain|upstream[|path]" entries, comma-separated. +# Create a new app (single domain) +panelctl init whoami whoami.srazka.com 18080 true -# Create a new app (single route, protected by Authelia) -panelctl init whoami "whoami.srazka.com|127.0.0.1:18080" true - -# Create with multiple routes (different ports, optional path) -panelctl init myapp "app.srazka.com|127.0.0.1:18081,api.srazka.com|127.0.0.1:18082|/api/*" true +# Create with multiple domains +panelctl init myapp "app.srazka.com,www.srazka.com" 18081 true # Create with wildcard domain (requires DNS challenge in Caddy) -panelctl init wild "*.srazka.com|127.0.0.1:18083" false - -# Change routes later (Caddy reloads automatically) -panelctl set-routes whoami "whoami.srazka.com|127.0.0.1:18080,who.srazka.com|127.0.0.1:18080" +panelctl init wild "*.srazka.com" 18082 false # Deploy (compose up + caddy reload) panelctl deploy whoami @@ -96,70 +91,9 @@ panelctl remove whoami ### Web UI features -- Live status: one `/status` poll every few seconds (faster while something is - running, paused when the tab is hidden) updates cards in place, so open tabs, - unsaved edits and scroll positions are never lost. The header shows when the - panel last synced and warns when the Authelia session has expired. -- Per-app status (running / partial / stopped), container list, and a busy - indicator that is shared between browsers while an operation runs. -- New-app dialog: starter container, pasted compose file or git repository; - suggests the next free port and a domain based on the app name. -- Compose editor with unsaved-changes tracking, Ctrl+S, save & deploy, validate. -- Logs with follow mode, routes editor with validation, file browser with - drag-and-drop upload, backups with restore (and optional redeploy). -- Git source tab: deployed commit, "check for updates", and sync & deploy. -- Activity drawer with the output of every operation (e.g. why a deploy failed). -- Keyboard: `/` search, `N` new app, `Esc` closes menus. Deep links like - `#/whoami/logs` open an app on a specific tab. - -### Git-backed apps - -Apps created from a repository (Forgejo, GitHub or any git host, over https or -ssh) are cloned to `stacks//repo`. - -**Forgejo.** `panel.nix` points the panel at the local Forgejo -(`PANEL_FORGEJO_URL`, `PANEL_FORGEJO_API_URL`, `PANEL_FORGEJO_SSH_URL`, taken -from `forgejo.nix`). In the panel's **Settings** you can connect a Forgejo -access token (read access to repositories and user). With it, the new-app -dialog lists your repositories and branches, and private ones are cloned over -https with the token. Without it, public repositories are listed and private -ones are cloned over ssh with the deploy key. Commit and compare links point at -Forgejo. The token is stored in `state/panel/forgejo-token` (mode 0600). - -**SSH / deploy key.** The panel generates an ed25519 key pair in -`state/panel/ssh/` the first time it is needed. Its public half is shown in -Settings (and next to ssh URLs); add it as a read-only deploy key to a -repository — or to your Forgejo account for access to all repositories — to -clone `ssh://git@git.srazka.com:14922/owner/repo.git` style URLs. **Sync** fetches the configured branch and -hard-resets the checkout to it before redeploying, so the repository is the -source of truth: compose edits made in the panel are discarded on the next sync -(the UI warns about this). An access token for a private repository is stored in -the clone's `.git/config`; use a read-only token. - -### Environment variables - -Each app can have environment variables (the **Environment** tab, or when -creating the app; `.env` text can be pasted in). They are stored in -`state/env/.env` as `KEY=VALUE` lines (mode 0600) — outside the repository -and stack directory, so git syncs never touch them — and `panelctl` passes them -to every compose command: - -- They are always available for `${VAR}` interpolation in the compose file. - The UI points out variables the compose file uses without a default that - aren't set. -- With **Pass to every container** (the default), `deploy`/`restart` also - generate `stacks//.panel-env.yaml`, a compose override that lists the - keys under every service's `environment:`. Compose reads the values from its - own environment, so they are never quoted into YAML, and they take - precedence over values set in the compose file. - -Values must be single-line. Names that would change how podman/compose run -(`PATH`, `HOME`, `XDG_*`, `DOCKER_*`, `COMPOSE_*`, `PODMAN_*`, …) are rejected. -Changes apply on the next deploy. Backups do not include variables. - -### Concurrency - -`panel-api` handles requests concurrently, so a long deploy never blocks status -or logs. Mutating operations are serialised per app — a second operation on a -busy app gets HTTP 409 — and `panelctl` takes a `flock` on the shared routes -file while rewriting it. +- Create apps with multiple domains and wildcard support +- Live container status indicators (auto-refreshes) +- Deploy, restart, stop, remove from the UI +- Inline compose editor with save, validate, and save+deploy +- Log viewer with configurable tail length +- Volume backup management: create, list, download, restore diff --git a/panel/frontend/index.html b/panel/frontend/index.html index 298ffb1..154e071 100644 --- a/panel/frontend/index.html +++ b/panel/frontend/index.html @@ -1,2877 +1,1191 @@ - - -Containers Panel - - + .spinner { + display: inline-block; + width: 14px; height: 14px; + border: 2px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .6s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } + - -
-
-
Panel
- -
- - - - -
-
-
- - -
-
-
-
/ search · N new app
-
-
-
-
-
+ +
+
+

Containers Panel

+

Rootless Podman + Caddy routes from one place.

+
+
+ +
+
- + +
Ready.
-
- + +
+ +
+
+

Create App

+ + - -
-
-

New app

- -
-
-
- - -

Lowercase letters, digits and dashes. Used for the compose project and data folder.

-
- -
- Source -
- - - + +
+
+
-

-
+

+ Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT. Optional path: /_/* +

- - -
- - -
-
-

Settings

- -
-
-
-

Forgejo

-

Loading…

-
- -
- - -
-

Create one in Forgejo under Settings → Applications with read access to repositories (and your user). - It lets the panel list your repositories and clone private ones over https. Stored in the panel's state directory, readable only by the service.

+
+
- -
-
-

SSH deploy key

-

Used for repositories cloned over SSH (e.g. ssh://git@host/owner/repo.git). Add it as a read-only deploy key in the repository's settings, or to your Forgejo account to give the panel access to all your repositories.

-
Loading…
-
+
- - - -
-

-
- -
- - + +
+
+
Loading apps...
+
-
- - -
- -
+
+ diff --git a/panel/panel-api.py b/panel/panel-api.py index 5e2edd6..ae01dfd 100644 --- a/panel/panel-api.py +++ b/panel/panel-api.py @@ -4,18 +4,12 @@ import json import os import re -import shlex import shutil -import socket import subprocess -import threading -import time -import urllib.error +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse, parse_qs import urllib.request -from concurrent.futures import ThreadPoolExecutor -from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import urlparse, parse_qs, quote +import urllib.error PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl") BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1") @@ -26,415 +20,11 @@ FRONTEND_DIR = os.environ.get( os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend"), ) -# Optional Forgejo instance (set from panel.nix). The API URL may be an internal -# address; the public URL is what repositories are cloned from and linked to. -FORGEJO_URL = os.environ.get("PANEL_FORGEJO_URL", "").rstrip("/") -FORGEJO_API_URL = (os.environ.get("PANEL_FORGEJO_API_URL", "") or FORGEJO_URL).rstrip("/") -FORGEJO_SSH_URL = os.environ.get("PANEL_FORGEJO_SSH_URL", "").rstrip("/") -FORGEJO_HOST = urlparse(FORGEJO_URL).hostname or "" - -PANEL_STATE_DIR = os.path.join(BASE_DIR, "state", "panel") -FORGEJO_TOKEN_FILE = os.path.join(PANEL_STATE_DIR, "forgejo-token") -SSH_DIR = os.path.join(PANEL_STATE_DIR, "ssh") -SSH_KEY = os.path.join(SSH_DIR, "id_ed25519") -ENV_DIR = os.path.join(BASE_DIR, "state", "env") - -COMPOSE_FILENAMES = ["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"] -GIT_TIMEOUT = 300 -# Manifest values are written into a file that panelctl sources with bash, so -# they must not contain anything that is special inside double quotes. -_URL_CHARS = r"[^\s\"'`$\\]" -REPO_URL_RE = re.compile( - rf"^(?:https?://{_URL_CHARS}+" # https://host/owner/repo.git - rf"|ssh://{_URL_CHARS}+" # ssh://git@host:port/owner/repo.git - rf"|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:{_URL_CHARS}+)$" # git@host:owner/repo.git -) -BRANCH_RE = re.compile(r"^[A-Za-z0-9._/][A-Za-z0-9._/-]*$") -FORGEJO_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") - -ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") -# Variables are set on the compose process itself, so anything that changes how -# podman/compose run (or where they look for state) is off limits. -RESERVED_ENV = {"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"} -RESERVED_ENV_PREFIXES = ("XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_", - "PODMAN_", "BUILDAH_", "LD_", "BASH_") - def is_safe_name(name): return re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name) is not None -# ── Per-app operation locks ── -# Requests are handled concurrently, so two mutating operations on the same app -# (e.g. a double-clicked deploy, or deploy + restore) must not overlap. - -_busy = {} -_busy_lock = threading.Lock() - - -class AppBusy(Exception): - def __init__(self, name, action): - super().__init__(f"another operation ({action}) is already running on '{name}'") - self.action = action - - -@contextmanager -def app_operation(name, action): - with _busy_lock: - if name in _busy: - raise AppBusy(name, _busy[name]) - _busy[name] = action - try: - yield - finally: - with _busy_lock: - _busy.pop(name, None) - - -def busy_snapshot(): - with _busy_lock: - return dict(_busy) - - -def redact_credentials(text, replacement="***@"): - """Hide user:token@ credentials embedded in http(s) URLs. - (ssh://git@host is a username, not a secret, and must be kept.)""" - return re.sub(r"(https?://)[^/@\s]+@", r"\1" + replacement, text or "") - - -def last_line(text): - lines = [line.strip() for line in (text or "").splitlines() if line.strip()] - return lines[-1] if lines else "" - - -def git_error(stderr): - """The informative line of a git failure (git ends with generic advice).""" - lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()] - for line in lines: - if re.match(r"^(ssh|fatal|error|remote):", line, re.I) and "could not read from remote" not in line.lower(): - return re.sub(r"^fatal:\s*", "", line) - return last_line(stderr) - - -def write_private_file(path, content): - """Atomically write a file only the service user can read.""" - os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) - tmp = f"{path}.tmp" - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as fh: - fh.write(content) - os.replace(tmp, path) - - -# ── SSH deploy key ── -# One key pair for the panel; add its public half as a (read-only) deploy key -# to repositories cloned over SSH. - -def ssh_public_key(create=True): - pub = SSH_KEY + ".pub" - if not os.path.isfile(pub) and create: - keygen = shutil.which("ssh-keygen") - if not keygen: - return None - os.makedirs(SSH_DIR, mode=0o700, exist_ok=True) - subprocess.run( - [keygen, "-t", "ed25519", "-N", "", "-q", "-C", f"panel@{socket.gethostname()}", "-f", SSH_KEY], - capture_output=True, check=False, timeout=30, - ) - try: - with open(pub, "r", encoding="utf-8") as fh: - return fh.read().strip() - except OSError: - return None - - -def is_ssh_url(url): - return not re.match(r"^https?://", url or "") - - -# ── Git helpers ── - -def git_env(): - # Never block on an interactive credential prompt. - env = dict(os.environ, GIT_TERMINAL_PROMPT="0") - if os.path.isfile(SSH_KEY): - env["GIT_SSH_COMMAND"] = " ".join([ - "ssh", "-i", shlex.quote(SSH_KEY), - "-o", "IdentitiesOnly=yes", - "-o", "BatchMode=yes", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "UserKnownHostsFile=" + shlex.quote(os.path.join(SSH_DIR, "known_hosts")), - ]) - return env - - -def run_git(args, cwd=None, timeout=GIT_TIMEOUT): - git_bin = shutil.which("git") - if not git_bin: - return {"ok": False, "stdout": "", "stderr": "git is not installed or not in PATH"} - cmd = [git_bin] + (["-C", cwd] if cwd else []) + args - try: - proc = subprocess.run(cmd, capture_output=True, text=True, env=git_env(), timeout=timeout) - except subprocess.TimeoutExpired: - return {"ok": False, "stdout": "", "stderr": f"git {args[0]} timed out after {timeout}s"} - return { - "ok": proc.returncode == 0, - "stdout": redact_credentials(proc.stdout.strip()), - "stderr": redact_credentials(proc.stderr.strip()), - } - - -def clone_repo(url, branch, target_dir, token=""): - auth_url = url - if token and not is_ssh_url(url): - auth_url = url.replace("://", f"://{quote(token, safe='')}@", 1) - elif is_ssh_url(url): - ssh_public_key() # make sure the deploy key exists before the first clone - args = ["clone"] - if branch: - args += ["--branch", branch] - return run_git(args + ["--", auth_url, target_dir]) - - -def repo_host_and_path(url): - url = redact_credentials(url or "", "") - m = (re.match(r"^https?://([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) - or re.match(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$", url) - or re.match(r"^(?:[^@/]+@)?([^/:]+):(?!/)(.+?)(?:\.git)?/?$", url)) - return (m.group(1), m.group(2)) if m else (None, None) - - -def repo_provider(url): - host, _ = repo_host_and_path(url) - if host and FORGEJO_HOST and host == FORGEJO_HOST: - return "forgejo" - if host in ("github.com", "www.github.com"): - return "github" - return "git" - - -def repo_web_url(url): - """Browser URL of a repository, for commit / compare links.""" - host, path = repo_host_and_path(url) - if not host: - return "" - if FORGEJO_HOST and host == FORGEJO_HOST: - return f"{FORGEJO_URL}/{path}" - m = re.match(r"^(https?)://", url or "") - return f"{m.group(1) if m else 'https'}://{host}/{path}" - - -# ── Forgejo ── - -class ForgejoError(Exception): - pass - - -def forgejo_token(): - try: - with open(FORGEJO_TOKEN_FILE, "r", encoding="utf-8") as fh: - return fh.read().strip() - except OSError: - return "" - - -def forgejo_api(path, token=None, timeout=10): - if not FORGEJO_API_URL: - raise ForgejoError("no Forgejo instance is configured") - token = forgejo_token() if token is None else token - req = urllib.request.Request(FORGEJO_API_URL + path, headers={"Accept": "application/json"}) - if token: - req.add_header("Authorization", f"token {token}") - try: - with urllib.request.urlopen(req, timeout=timeout) as res: - return json.loads(res.read().decode("utf-8") or "null") - except urllib.error.HTTPError as exc: - if exc.code in (401, 403): - raise ForgejoError("Forgejo rejected the token") from exc - if exc.code == 404: - raise ForgejoError("not found on Forgejo (or no access)") from exc - raise ForgejoError(f"Forgejo answered HTTP {exc.code}") from exc - except (urllib.error.URLError, TimeoutError, ValueError) as exc: - raise ForgejoError(f"can't reach Forgejo: {getattr(exc, 'reason', exc)}") from exc - - -def is_forgejo_https_url(url): - return bool(FORGEJO_URL) and not is_ssh_url(url) and repo_provider(url) == "forgejo" - - -# ── Environment variables ── -# Stored per app in state/env/.env as KEY=VALUE lines (0600). panelctl -# passes them to every compose command, so they work for ${VAR} interpolation -# and — unless disabled — are injected into every service. - -def env_file_path(name): - return os.path.join(ENV_DIR, f"{name}.env") - - -def read_app_env(name): - items = [] - try: - with open(env_file_path(name), "r", encoding="utf-8") as fh: - lines = fh.read().splitlines() - except OSError: - return items - for line in lines: - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - items.append({"key": key, "value": value}) - return items - - -def validate_env(items): - if items is None: - return [] - if not isinstance(items, list): - raise ValueError("env must be a list of {key, value}") - seen = set() - out = [] - for item in items: - if not isinstance(item, dict): - raise ValueError("env must be a list of {key, value}") - key = str(item.get("key", "")).strip() - value = item.get("value", "") - value = "" if value is None else str(value) - if not key and not value: - continue - if not ENV_KEY_RE.match(key): - raise ValueError(f"'{key}' is not a valid variable name (letters, digits and _, not starting with a digit)") - if key in RESERVED_ENV or key.startswith(RESERVED_ENV_PREFIXES): - raise ValueError(f"'{key}' is reserved because it would change how podman/compose run") - if key in seen: - raise ValueError(f"'{key}' is set more than once") - if any(c in value for c in "\n\r\0"): - raise ValueError(f"the value of '{key}' must be a single line") - seen.add(key) - out.append({"key": key, "value": value}) - return out - - -def write_app_env(name, items): - path = env_file_path(name) - if not items: - try: - os.remove(path) - except FileNotFoundError: - pass - return - write_private_file(path, "".join(f"{i['key']}={i['value']}\n" for i in items)) - - -def repo_commit(repo_dir, ref="HEAD"): - result = run_git(["log", "-1", "--format=%H%x1f%s%x1f%an%x1f%ct", ref], cwd=repo_dir, timeout=15) - if not result["ok"] or not result["stdout"]: - return None - sha, subject, author, ts = (result["stdout"].split("\x1f") + ["", "", "", ""])[:4] - return { - "sha": sha, - "short": sha[:7], - "subject": subject, - "author": author, - "time": int(ts) if ts.isdigit() else None, - } - - -def repo_current_branch(repo_dir): - result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_dir, timeout=15) - if result["ok"] and result["stdout"] and result["stdout"] != "HEAD": - return result["stdout"] - return "" - - -def find_compose_file(repo_dir): - for fname in COMPOSE_FILENAMES: - candidate = os.path.join(repo_dir, fname) - if os.path.isfile(candidate): - return candidate - return None - - -# ── Manifest helpers ── - -def update_manifest(name, values): - """Set KEY="value" lines in an app manifest, replacing existing keys.""" - for key, value in values.items(): - if re.search(r'["`$\\\n]', value): - raise ValueError(f"unsafe characters in {key}") - manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") - with open(manifest_path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - remaining = dict(values) - out = [] - for line in lines: - key = line.split("=", 1)[0].strip() - if key in remaining: - out.append(f'{key}="{remaining.pop(key)}"\n') - else: - out.append(line if line.endswith("\n") else line + "\n") - for key, value in remaining.items(): - out.append(f'{key}="{value}"\n') - with open(manifest_path, "w", encoding="utf-8") as fh: - fh.writelines(out) - - -def manifest_routes(env): - routes_raw = env.get("APP_ROUTES", "") - # Backward compat: build from old APP_DOMAIN/APP_PORT/APP_UPSTREAM - if not routes_raw and "APP_DOMAIN" in env: - upstream = env.get("APP_UPSTREAM", f"127.0.0.1:{env.get('APP_PORT', '18080')}") - domains = env.get("APP_DOMAINS", env["APP_DOMAIN"]) - routes_raw = ",".join(f"{d.strip()}|{upstream}" for d in domains.split(",") if d.strip()) - routes = [] - for entry in routes_raw.split(","): - entry = entry.strip() - if not entry: - continue - fields = entry.split("|", 2) - if len(fields) < 2: - continue - route = {"domain": fields[0].strip(), "upstream": fields[1].strip()} - if len(fields) > 2 and fields[2].strip(): - route["path"] = fields[2].strip() - routes.append(route) - return routes - - -def load_app_summaries(): - """Read every app manifest directly (much faster than shelling out per app).""" - apps_dir = os.path.join(BASE_DIR, "state", "apps") - try: - entries = sorted(os.listdir(apps_dir)) - except OSError: - return [] - apps = [] - for fname in entries: - if not fname.endswith(".env"): - continue - name = fname[:-4] - if not is_safe_name(name): - continue - try: - with open(os.path.join(apps_dir, fname), "r", encoding="utf-8") as fh: - env = parse_env_blob(fh.read()) - except OSError: - continue - repo_url = redact_credentials(env.get("APP_REPO_URL", ""), "") - apps.append({ - "name": name, - "routes": manifest_routes(env), - "auth": env.get("APP_AUTH_PROTECTED", "true") == "true", - "compose_file": env.get("APP_COMPOSE_FILE", ""), - "repo_url": repo_url, - "repo_branch": env.get("APP_REPO_BRANCH", ""), - "repo_provider": repo_provider(repo_url) if repo_url else "", - "repo_web_url": repo_web_url(repo_url) if repo_url else "", - "env_count": len(read_app_env(name)), - "env_inject": env.get("APP_ENV_INJECT", "true") != "false", - }) - return apps - - def run_panelctl(args): proc = subprocess.run( [PANELCTL, *args], @@ -464,12 +54,11 @@ def parse_env_blob(blob): def get_app_volumes(name): result = run_panelctl(["inspect-volumes", name]) volumes = {} - # Parse whatever was printed even on a non-zero exit, so one failing - # `podman volume ls` doesn't hide the app's default data folder. - for line in result["stdout"].splitlines(): - if "|" in line: - vname, vpath = line.split("|", 1) - volumes[vname.strip()] = vpath.strip() + if result["ok"]: + for line in result["stdout"].splitlines(): + if "|" in line: + vname, vpath = line.split("|", 1) + volumes[vname.strip()] = vpath.strip() return volumes def read_app_info(name): @@ -494,92 +83,36 @@ def read_app_info(name): return app, None -def _decode_containers(stdout): - """`compose ps --format json` prints either a JSON array or one object per line, - sometimes mixed with other output. Returns a list of dicts, or None.""" - lines = stdout.splitlines() - for i, line in enumerate(lines): - if line.lstrip().startswith("["): - try: - data, _ = json.JSONDecoder().raw_decode("\n".join(lines[i:]).lstrip()) - except ValueError: - continue - if isinstance(data, list): - return [c for c in data if isinstance(c, dict)] - items = [] - for line in lines: - line = line.strip() - if not line.startswith("{"): - continue - try: - obj = json.loads(line) - except ValueError: - continue - if isinstance(obj, dict): - items.append(obj) - return items or None - - -def _container_name(c): - name = c.get("Name") or c.get("name") - if not name: - names = c.get("Names") - if isinstance(names, list) and names: - name = names[0] - elif isinstance(names, str): - name = names - return name or "?" - - def parse_status_output(stdout): - """Summarise panelctl status output as running / partial / stopped / unknown.""" - stdout = stdout or "" - containers = _decode_containers(stdout) - if containers is None: - text = stdout.lower() - if not text.strip() or "no containers" in text: - return {"state": "stopped", "running": False, "running_count": 0, "total": 0, "containers": []} - running = re.search(r"\b(up|running)\b", text) is not None - return { - "state": "running" if running else "unknown", - "running": running, - "running_count": None, - "total": None, - "containers": [], - "raw": stdout, - } - - parsed = [] - for c in containers: - state = str(c.get("State") or c.get("state") or "").lower() - status = str(c.get("Status") or c.get("status") or "") - is_running = state == "running" or status.lower().startswith("up") - parsed.append({ - "name": _container_name(c), - "state": state or ("running" if is_running else "unknown"), - "status": status, - "image": c.get("Image") or c.get("image") or "", - "running": is_running, - }) - running_count = sum(1 for c in parsed if c["running"]) - total = len(parsed) - if total and running_count == total: - state = "running" - elif running_count: - state = "partial" - else: - state = "stopped" - return { - "state": state, - "running": running_count > 0, - "running_count": running_count, - "total": total, - "containers": parsed, - } - - -def app_status(name): - return parse_status_output(run_panelctl(["status", name])["stdout"]) + """Try to determine if any container is running from panelctl status output.""" + text = stdout.lower() + if not text or "no containers" in text: + return {"running": False, "raw": stdout} + # podman compose ps --format json returns JSON array + try: + containers = json.loads(stdout) + if isinstance(containers, list): + running = any( + c.get("State", "").lower() == "running" + or c.get("status", "").lower().startswith("up") + for c in containers + ) + return { + "running": running, + "containers": [ + { + "name": c.get("Name", c.get("name", "?")), + "state": c.get("State", c.get("status", "unknown")), + "image": c.get("Image", c.get("image", "")), + } + for c in containers + ], + } + except (json.JSONDecodeError, TypeError): + pass + # Fallback: check for "Up" or "running" in text + running = "up" in text or "running" in text + return {"running": running, "raw": stdout} def parse_backups_output(stdout): @@ -603,10 +136,6 @@ def parse_backups_output(stdout): return backups -# Actions that only read state and may run alongside anything else. -LOCK_FREE_ACTIONS = {"validate-compose"} - - class Handler(BaseHTTPRequestHandler): def _html(self, code, body): payload = body.encode("utf-8") @@ -631,24 +160,17 @@ class Handler(BaseHTTPRequestHandler): self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) - # The UI is a single file that changes with every rebuild. - self.send_header("Cache-Control", "no-cache") self.end_headers() self.wfile.write(data) except OSError: self._json(500, {"ok": False, "error": "failed to read file"}) def _read_json(self): - # Cached: do_POST may read the body before dispatching. - if hasattr(self, "_payload"): - return self._payload length = int(self.headers.get("Content-Length", "0")) if length == 0: - self._payload = {} - else: - raw = self.rfile.read(length) - self._payload = json.loads(raw.decode("utf-8")) - return self._payload + return {} + raw = self.rfile.read(length) + return json.loads(raw.decode("utf-8")) def log_message(self, fmt, *args): # Log to stdout (goes to systemd journal) @@ -680,92 +202,6 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "service": "panel-api"}) return - # /integrations — Forgejo connection and the panel's SSH deploy key - if path == "/integrations": - token = forgejo_token() - forgejo = { - "configured": bool(FORGEJO_URL), - "url": FORGEJO_URL, - "ssh_url": FORGEJO_SSH_URL, - "has_token": bool(token), - "user": None, - } - if FORGEJO_URL and token: - try: - forgejo["user"] = (forgejo_api("/api/v1/user", timeout=5) or {}).get("login") - except ForgejoError as exc: - forgejo["error"] = str(exc) - self._json(200, {"ok": True, "forgejo": forgejo, "ssh": {"public_key": ssh_public_key()}}) - return - - # /forgejo/repos?q= — repositories visible to the stored token (public ones without) - if path == "/forgejo/repos": - q = query.get("q", [""])[0].strip() - try: - data = forgejo_api(f"/api/v1/repos/search?q={quote(q)}&limit=50&sort=updated&order=desc") - except ForgejoError as exc: - self._json(502, {"ok": False, "error": str(exc)}) - return - repos = [{ - "full_name": r.get("full_name", ""), - "description": r.get("description", ""), - "private": bool(r.get("private")), - "empty": bool(r.get("empty")), - "archived": bool(r.get("archived")), - "default_branch": r.get("default_branch", ""), - "clone_url": r.get("clone_url", ""), - "ssh_url": r.get("ssh_url", ""), - "html_url": r.get("html_url", ""), - "updated_at": r.get("updated_at", ""), - } for r in (data or {}).get("data", [])] - self._json(200, {"ok": True, "repos": repos, "authenticated": bool(forgejo_token())}) - return - - # /forgejo/branches?repo=owner/name - if path == "/forgejo/branches": - repo = query.get("repo", [""])[0].strip() - if not FORGEJO_REPO_RE.match(repo): - self._json(400, {"ok": False, "error": "repo must look like owner/name"}) - return - try: - data = forgejo_api(f"/api/v1/repos/{repo}/branches?limit=100") - except ForgejoError as exc: - self._json(502, {"ok": False, "error": str(exc)}) - return - self._json(200, {"ok": True, "branches": [b.get("name", "") for b in (data or [])]}) - return - - # /apps//env — environment variables used when deploying - if len(parts) == 3 and parts[0] == "apps" and parts[2] == "env": - name = parts[1] - app, err = read_app_info(name) - if err is not None: - self._json(404, err) - return - self._json(200, { - "ok": True, - "name": name, - "vars": read_app_env(name), - "inject": app.get("APP_ENV_INJECT", "true") != "false", - }) - return - - # /status — every app with routes, container status and running operation. - # This is what the UI polls, so it is one request regardless of app count. - if path == "/status": - apps = load_app_summaries() - names = [a["name"] for a in apps] - statuses = {} - if names: - with ThreadPoolExecutor(max_workers=min(8, len(names))) as pool: - statuses = dict(zip(names, pool.map(app_status, names))) - busy = busy_snapshot() - for app in apps: - app["status"] = statuses.get(app["name"], {"state": "unknown"}) - app["busy"] = busy.get(app["name"]) - self._json(200, {"ok": True, "time": int(time.time()), "apps": apps}) - return - if path == "/apps": result = run_panelctl(["list"]) if not result["ok"]: @@ -898,51 +334,31 @@ class Handler(BaseHTTPRequestHandler): if not result["ok"]: self._json(404, result) return - routes = manifest_routes(parse_env_blob(result["stdout"])) + env = parse_env_blob(result["stdout"]) + routes_raw = env.get("APP_ROUTES", "") + # Backward compat: build from old APP_DOMAIN/APP_PORT/APP_UPSTREAM + if not routes_raw and "APP_DOMAIN" in env: + upstream = env.get("APP_UPSTREAM", f"127.0.0.1:{env.get('APP_PORT', '18080')}") + domains_str = env.get("APP_DOMAINS", env["APP_DOMAIN"]) + routes_parts = [] + for d in domains_str.split(","): + d = d.strip() + if d: + routes_parts.append(f"{d}|{upstream}") + routes_raw = ",".join(routes_parts) + routes = [] + for entry in routes_raw.split(","): + entry = entry.strip() + if not entry: + continue + parts = entry.split("|", 2) + route = {"domain": parts[0].strip(), "upstream": parts[1].strip()} + if len(parts) > 2: + route["path"] = parts[2].strip() + routes.append(route) self._json(200, {"ok": True, "name": name, "routes": routes}) return - # /apps//repo[?fetch=1] — git source info; fetch=1 also checks the remote - if len(parts) == 3 and parts[0] == "apps" and parts[2] == "repo": - name = parts[1] - app, err = read_app_info(name) - if err is not None: - self._json(404, err) - return - repo_url = app.get("APP_REPO_URL", "") - if not repo_url: - self._json(404, {"ok": False, "error": "app is not linked to a git repository"}) - return - repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") - branch = app.get("APP_REPO_BRANCH", "") - info = { - "ok": True, - "name": name, - "url": redact_credentials(repo_url, ""), - "web_url": repo_web_url(repo_url), - "provider": repo_provider(repo_url), - "ssh": is_ssh_url(repo_url), - "branch": branch, - "cloned": os.path.isdir(os.path.join(repo_dir, ".git")), - } - if info["ssh"]: - info["public_key"] = ssh_public_key() - if info["cloned"]: - info["commit"] = repo_commit(repo_dir) - status = run_git(["status", "--porcelain", "--untracked-files=no"], cwd=repo_dir, timeout=15) - info["dirty"] = bool(status["stdout"]) if status["ok"] else None - if query.get("fetch", ["0"])[0] == "1": - ref = branch or repo_current_branch(repo_dir) - fetched = run_git(["fetch", "--quiet", "origin", ref], cwd=repo_dir) - if not fetched["ok"]: - info["fetch_error"] = git_error(fetched["stderr"]) or "git fetch failed" - else: - info["remote"] = repo_commit(repo_dir, "FETCH_HEAD") - count = run_git(["rev-list", "--count", "HEAD..FETCH_HEAD"], cwd=repo_dir, timeout=15) - info["behind"] = int(count["stdout"]) if count["ok"] and count["stdout"].isdigit() else None - self._json(200, info) - return - # /apps/ — show single app if len(parts) == 2 and parts[0] == "apps": name = parts[1] @@ -1159,28 +575,6 @@ class Handler(BaseHTTPRequestHandler): def do_POST(self): path, parts, query = self._parse_path() - name, action = None, None - if path == "/apps/init": - try: - payload = self._read_json() - except Exception as exc: - self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) - return - name = str(payload.get("name", "")) if isinstance(payload, dict) else "" - action = "init" - elif len(parts) >= 3 and parts[0] == "apps" and parts[2] not in LOCK_FREE_ACTIONS: - name, action = parts[1], parts[2] - - if not name: - self._handle_post(path, parts, query) - return - try: - with app_operation(name, action): - self._handle_post(path, parts, query) - except AppBusy as exc: - self._json(409, {"ok": False, "error": str(exc), "busy": exc.action}) - - def _handle_post(self, path, parts, query): # POST /apps/init if path == "/apps/init": try: @@ -1220,34 +614,10 @@ class Handler(BaseHTTPRequestHandler): self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) return - if source_type == "github": # older clients - source_type = "git" - if source_type not in ["default", "raw", "git"]: + if source_type not in ["default", "raw", "github"]: self._json(400, {"ok": False, "error": "invalid source_type"}) return - # Validate everything before creating anything. - try: - env_items = validate_env(payload.get("env")) - except ValueError as exc: - self._json(400, {"ok": False, "error": str(exc)}) - return - env_inject = payload.get("env_inject", True) is not False - - if source_type == "git": - repo_url = str(payload.get("repo_url") or payload.get("github_url") or "").strip() - branch = str(payload.get("repo_branch") or payload.get("github_branch") or "").strip() - token = str(payload.get("repo_token") or payload.get("github_pat") or "").strip() - if not REPO_URL_RE.match(repo_url) or repo_url.startswith("-"): - self._json(400, {"ok": False, "error": "repository URL must be an https://, ssh:// or git@host:owner/repo URL"}) - return - if branch and not BRANCH_RE.match(branch): - self._json(400, {"ok": False, "error": f"invalid branch name '{branch}'"}) - return - # Clone a Forgejo repository with the panel's stored token. - if not token and payload.get("use_forgejo_token") and is_forgejo_https_url(repo_url): - token = forgejo_token() - try: result = run_panelctl(["init", name, routes_str, auth]) if not result["ok"]: @@ -1271,117 +641,105 @@ class Handler(BaseHTTPRequestHandler): self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"}) return - summary = "initialized successfully" - if source_type == "git": - # Any git host works (Forgejo, GitHub, ...), over https or ssh. - # An https token is embedded in the clone URL, so later syncs - # reuse it from .git/config; ssh uses the panel's deploy key. + elif source_type == "github": + repo_url = payload.get("github_url", "").strip() + branch = payload.get("github_branch", "main").strip() + pat = payload.get("github_pat", "").strip() + + if not repo_url: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": "github_url is required"}) + return + + # Validate URL and extract owner/repo + match = re.match(r'^https?://(?:www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?$', repo_url) + if not match: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": "invalid github_url format"}) + return + + owner, repo = match.groups() + + if pat: + # Check GitHub API access + api_url = f"https://api.github.com/repos/{owner}/{repo}" + req = urllib.request.Request(api_url, headers={"Authorization": f"Bearer {pat}"}) + try: + urllib.request.urlopen(req) + except urllib.error.URLError as e: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": f"github api check failed: {e.reason}"}) + return + + # Clone the repository + git_bin = shutil.which("git") + if not git_bin: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": "git is not installed or not in PATH"}) + return + target_dir = os.path.join(app["APP_STACK_DIR"], "repo") if os.path.exists(target_dir): shutil.rmtree(target_dir) - cloned = clone_repo(repo_url, branch, target_dir, token) - if not cloned["ok"]: - run_panelctl(["remove", name]) - self._json(400, { - "ok": False, - "error": f"git clone failed: {git_error(cloned['stderr'])}", - "stderr": cloned["stderr"], - }) - return - branch = branch or repo_current_branch(target_dir) or "main" + auth_url = repo_url + if pat: + auth_url = auth_url.replace("://", f"://{pat}@") + + if not auth_url.endswith(".git"): + auth_url += ".git" - compose_path = find_compose_file(target_dir) + clone_result = subprocess.run( + [git_bin, "clone", "--branch", branch, auth_url, target_dir], + capture_output=True, text=True + ) + + if clone_result.returncode != 0: + run_panelctl(["remove", name]) + self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) + return + + # Find compose file + compose_path = None + for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]: + candidate = os.path.join(target_dir, fname) + if os.path.isfile(candidate): + compose_path = candidate + break + if not compose_path: run_panelctl(["remove", name]) - self._json(400, {"ok": False, "error": "could not find a compose file in the repository root"}) + self._json(400, {"ok": False, "error": "could not find compose file in repository root"}) return - + + # Update manifest + manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") try: - update_manifest(name, { - "APP_COMPOSE_FILE": compose_path, - "APP_REPO_URL": redact_credentials(repo_url, ""), - "APP_REPO_BRANCH": branch, - }) - except (OSError, ValueError) as exc: + with open(manifest_path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + with open(manifest_path, "w", encoding="utf-8") as fh: + for line in lines: + if line.startswith("APP_COMPOSE_FILE="): + fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n') + else: + fh.write(line) + fh.write(f'APP_REPO_URL="{repo_url}"\n') + fh.write(f'APP_REPO_BRANCH="{branch}"\n') + except OSError as exc: run_panelctl(["remove", name]) self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) return - commit = repo_commit(target_dir) - summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned" - - if env_items or not env_inject: - write_app_env(name, env_items) - update_manifest(name, {"APP_ENV_INJECT": "true" if env_inject else "false"}) - summary += f"\n{len(env_items)} environment variable(s) set" - - self._json(200, {"ok": True, "code": 0, "stdout": summary}) + self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"}) except Exception as exc: run_panelctl(["remove", name]) self._json(500, {"ok": False, "error": f"init failed: {exc}"}) return - # POST /integrations/forgejo {"token": "..."} — verify and store ("" clears it) - if path == "/integrations/forgejo": - if not FORGEJO_URL: - self._json(400, {"ok": False, "error": "no Forgejo instance is configured (PANEL_FORGEJO_URL)"}) - return - try: - token = str(self._read_json().get("token", "")).strip() - except Exception as exc: - self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) - return - if not token: - try: - os.remove(FORGEJO_TOKEN_FILE) - except FileNotFoundError: - pass - self._json(200, {"ok": True, "has_token": False}) - return - try: - user = (forgejo_api("/api/v1/user", token=token) or {}).get("login") - except ForgejoError as exc: - self._json(400, {"ok": False, "error": str(exc)}) - return - write_private_file(FORGEJO_TOKEN_FILE, token + "\n") - self._json(200, {"ok": True, "has_token": True, "user": user}) - return - if len(parts) >= 3 and parts[0] == "apps": name = parts[1] action = parts[2] - # POST /apps//env — replace environment variables, optionally redeploy - if action == "env": - app, err = read_app_info(name) - if err is not None: - self._json(404, err) - return - try: - payload = self._read_json() - items = validate_env(payload.get("vars", [])) - except ValueError as exc: - self._json(400, {"ok": False, "error": str(exc)}) - return - inject = payload.get("inject", True) is not False - try: - write_app_env(name, items) - update_manifest(name, {"APP_ENV_INJECT": "true" if inject else "false"}) - except (OSError, ValueError) as exc: - self._json(500, {"ok": False, "error": f"failed to save variables: {exc}"}) - return - result = {"ok": True, "name": name, "count": len(items), - "stdout": f"saved {len(items)} environment variable(s)"} - if payload.get("deploy"): - deployed = run_panelctl(["deploy", name]) - deployed["stdout"] = "\n".join(filter(None, [result["stdout"], deployed["stdout"]])) - deployed["count"] = len(items) - self._json(200 if deployed["ok"] else 400, deployed) - return - self._json(200, result) - return - # POST /apps//compose — save compose file if action == "compose": app, err = read_app_info(name) @@ -1481,82 +839,96 @@ class Handler(BaseHTTPRequestHandler): self._json(200 if result["ok"] else 400, result) return - # POST /apps//repo-pull — sync the checkout to the remote branch and redeploy. - # The repository is the source of truth: fetch + hard reset, so local - # edits or force-pushes never leave the checkout stuck mid-merge. + # POST /apps//repo-pull — re-clone/pull repo and redeploy if action == "repo-pull": if not is_safe_name(name): self._json(400, {"ok": False, "error": "invalid app name"}) return - app, err = read_app_info(name) - if err is not None or app is None: - self._json(404, {"ok": False, "error": "app not found"}) - return - repo_url = app.get("APP_REPO_URL", "").strip() - if not repo_url: - self._json(400, {"ok": False, "error": "app is not linked to a git repository"}) - return - - repo_dir = os.path.join(app["APP_STACK_DIR"], "repo") - branch = app.get("APP_REPO_BRANCH", "").strip() - git_log = [] - before = None - - if os.path.isdir(os.path.join(repo_dir, ".git")): - before = repo_commit(repo_dir) - ref = branch or repo_current_branch(repo_dir) - if not ref: - self._json(400, {"ok": False, "error": "cannot determine which branch to sync"}) - return - fetched = run_git(["fetch", "origin", ref], cwd=repo_dir) - if not fetched["ok"]: - self._json(400, { - "ok": False, - "error": f"git fetch failed: {git_error(fetched['stderr'])}", - "stderr": fetched["stderr"], - }) - return - reset = run_git(["reset", "--hard", "FETCH_HEAD"], cwd=repo_dir, timeout=60) - if not reset["ok"]: - self._json(400, { - "ok": False, - "error": f"git reset failed: {git_error(reset['stderr'])}", - "stderr": reset["stderr"], - }) - return - git_log.append(reset["stdout"]) - else: - # No checkout yet (e.g. deleted by hand): clone it fresh. - if os.path.exists(repo_dir): - shutil.rmtree(repo_dir) - cloned = clone_repo(repo_url, branch, repo_dir) - if not cloned["ok"]: - self._json(400, { - "ok": False, - "error": f"git clone failed: {git_error(cloned['stderr'])}", - "stderr": cloned["stderr"], - }) - return - git_log.append("cloned repository") - - after = repo_commit(repo_dir) - compose_path = find_compose_file(repo_dir) - if not compose_path: - self._json(400, {"ok": False, "error": "compose file not found in repository root"}) - return try: - update_manifest(name, {"APP_COMPOSE_FILE": compose_path}) - except (OSError, ValueError) as exc: - self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) - return + app, err = read_app_info(name) + if err is not None or app is None: + self._json(404, {"ok": False, "error": "app not found"}) + return - result = run_panelctl(["deploy", name]) - result["stdout"] = "\n".join(filter(None, git_log + [result["stdout"]])) - result["before"] = before - result["after"] = after - result["changed"] = not before or not after or before["sha"] != after["sha"] - self._json(200 if result["ok"] else 400, result) + repo_url = app.get("APP_REPO_URL", "").strip() + branch = app.get("APP_REPO_BRANCH", "main").strip() + + if not repo_url: + self._json(400, {"ok": False, "error": "app has no APP_REPO_URL"}) + return + + git_bin = shutil.which("git") + if not git_bin: + self._json(400, {"ok": False, "error": "git is not installed"}) + return + + target_dir = os.path.join(app["APP_STACK_DIR"], "repo") + pat = "" + + auth_url = repo_url + if pat: + auth_url = auth_url.replace("://", f"://{pat}@") + if not auth_url.endswith(".git"): + auth_url += ".git" + + if os.path.exists(target_dir): + # Already cloned — try git pull + pull_result = subprocess.run( + [git_bin, "-C", target_dir, "pull", "origin", branch], + capture_output=True, text=True + ) + if pull_result.returncode != 0: + # Fall back to re-clone + shutil.rmtree(target_dir) + clone_result = subprocess.run( + [git_bin, "clone", "--branch", branch, auth_url, target_dir], + capture_output=True, text=True + ) + if clone_result.returncode != 0: + self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) + return + else: + clone_result = subprocess.run( + [git_bin, "clone", "--branch", branch, auth_url, target_dir], + capture_output=True, text=True + ) + if clone_result.returncode != 0: + self._json(400, {"ok": False, "error": f"git clone failed: {clone_result.stderr.strip()}"}) + return + + # Find compose file + compose_path = None + for fname in ["compose.yaml", "docker-compose.yml", "compose.yml", "docker-compose.yaml"]: + candidate = os.path.join(target_dir, fname) + if os.path.isfile(candidate): + compose_path = candidate + break + + if not compose_path: + self._json(400, {"ok": False, "error": "compose file not found in repository"}) + return + + # Update manifest compose path + manifest_path = os.path.join(BASE_DIR, "state", "apps", f"{name}.env") + try: + with open(manifest_path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + with open(manifest_path, "w", encoding="utf-8") as fh: + for line in lines: + if line.startswith("APP_COMPOSE_FILE="): + fh.write(f'APP_COMPOSE_FILE="{compose_path}"\n') + else: + fh.write(line) + except OSError as exc: + self._json(500, {"ok": False, "error": f"failed to update manifest: {exc}"}) + return + + # Redeploy + result = run_panelctl(["deploy", name]) + self._json(200 if result["ok"] else 400, result) + except Exception as exc: + self._json(500, {"ok": False, "error": f"repo-pull failed: {exc}"}) return # POST /apps//remove @@ -1580,8 +952,7 @@ class Handler(BaseHTTPRequestHandler): def main(): - server = ThreadingHTTPServer((BIND, PORT), Handler) - server.daemon_threads = True + server = HTTPServer((BIND, PORT), Handler) print(f"panel-api listening on http://{BIND}:{PORT}") print(f"frontend dir: {FRONTEND_DIR}") server.serve_forever() diff --git a/panel/panelctl.sh b/panel/panelctl.sh index 4e9d056..4490418 100644 --- a/panel/panelctl.sh +++ b/panel/panelctl.sh @@ -7,15 +7,8 @@ VOLUMES_DIR="${BASE_DIR}/volumes" ROUTES_DIR="${BASE_DIR}/routes" STATE_DIR="${BASE_DIR}/state" APPS_DIR="${STATE_DIR}/apps" -ENV_DIR="${STATE_DIR}/env" BACKUPS_DIR="${BASE_DIR}/backups" -# Set by load_app: compose file arguments, and the app's environment variables -# as KEY=VALUE words (passed to compose via env(1), never sourced). -COMPOSE_ARGS=() -APP_ENV_ARGS=() -APP_ENV_KEYS=() - FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Email Remote-Name @@ -156,19 +149,6 @@ app_route_file() { echo "${ROUTES_DIR}/routes.caddy" } -# The routes file is shared by all apps and rewritten read-modify-write, so -# concurrent panelctl runs (the API handles requests in parallel) must take turns. -routes_lock() { - exec 9>"${ROUTES_DIR}/.routes.lock" - if command -v flock >/dev/null 2>&1; then - flock -w 30 9 || fail "timed out waiting for the routes file lock" - fi -} - -routes_unlock() { - exec 9>&- -} - load_app() { local name="$1" local manifest @@ -193,75 +173,6 @@ load_app() { done APP_ROUTES="${routes}" fi - - load_app_env "${name}" -} - -app_env_file() { - echo "${ENV_DIR}/$1.env" -} - -# Generated compose override that passes the app's variables into every service. -app_env_override() { - echo "${STACKS_DIR}/$1/.panel-env.yaml" -} - -load_app_env() { - local name="$1" - local file line key override - file="$(app_env_file "${name}")" - APP_ENV_ARGS=() - APP_ENV_KEYS=() - if [[ -f "${file}" ]]; then - while IFS= read -r line || [[ -n "${line}" ]]; do - [[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue - key="${line%%=*}" - [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue - APP_ENV_ARGS+=("${line}") - APP_ENV_KEYS+=("${key}") - done <"${file}" - fi - - COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") - override="$(app_env_override "${name}")" - if [[ -f "${override}" ]]; then - COMPOSE_ARGS+=(-f "${override}") - fi -} - -# (Re)generate the env override before containers are created. Variables are -# always available for ${VAR} interpolation; with APP_ENV_INJECT (default true) -# every service also receives them. Bare keys make compose read the values from -# its own environment, so values never have to be quoted into YAML. -prepare_env_override() { - local name="$1" - local override services svc key tmp - override="$(app_env_override "${name}")" - - if [[ ${#APP_ENV_KEYS[@]} -eq 0 || "${APP_ENV_INJECT:-true}" != "true" ]]; then - rm -f "${override}" - COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}") - return - fi - - services="$(run_compose -f "${APP_COMPOSE_FILE}" config --services 2>/dev/null)" \ - || fail "could not list compose services to pass environment variables (is the compose file valid?)" - - tmp="$(mktemp)" - { - echo "# Generated by panelctl from the app's environment variables. Do not edit." - echo "services:" - while IFS= read -r svc; do - [[ "${svc}" =~ ^[A-Za-z0-9._-]+$ ]] || continue - printf ' "%s":\n environment:\n' "${svc}" - for key in "${APP_ENV_KEYS[@]}"; do - printf ' - %s\n' "${key}" - done - done <<<"${services}" - } >"${tmp}" - install -m 0640 "${tmp}" "${override}" - rm -f "${tmp}" - COMPOSE_ARGS=(-f "${APP_COMPOSE_FILE}" -f "${override}") } compose_command() { @@ -328,11 +239,11 @@ run_compose() { if [[ "${compose}" == *" compose" ]]; then local podman_bin="${compose% compose}" - env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@" + "${podman_bin}" compose "$@" return fi - env "${APP_ENV_ARGS[@]}" "${compose}" "$@" + "${compose}" "$@" } write_default_compose() { @@ -426,8 +337,6 @@ cmd_render_route() { local route_file route_file="$(app_route_file)" - routes_lock - # Strip any existing block for this app from the aggregate file. local tmp tmp="$(mktemp)" @@ -467,8 +376,6 @@ cmd_render_route() { } >>"${tmp}" install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" - rm -f "${tmp}" - routes_unlock log info "rendered route ${route_file}" } @@ -480,19 +387,11 @@ cmd_deploy() { log info "Starting deployment for app '${name}'" cmd_render_route "${name}" - prepare_env_override "${name}" - # Capture compose output so callers (the web UI) can show why a deploy failed, - # and still forward it to the journal. - local output - if ! output="$(run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1)"; then - printf '%s\n' "${output}" | systemd-cat -t panelctl -p err 2>/dev/null || true - printf '%s\n' "${output}" >&2 + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then log err "Deployment failed for app '${name}'" fail "compose up failed" fi - printf '%s\n' "${output}" | systemd-cat -t panelctl -p info 2>/dev/null || true - printf '%s\n' "${output}" log info "Successfully deployed app '${name}'" } @@ -504,10 +403,9 @@ cmd_restart() { log info "Restarting app '${name}'" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" - prepare_env_override "${name}" + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" - if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then + if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1; then fail "compose up failed during restart" fi @@ -519,7 +417,7 @@ cmd_stop() { validate_name "${name}" load_app "${name}" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed" + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed" log info "stopped app '${name}'" } @@ -528,8 +426,8 @@ cmd_status() { validate_name "${name}" load_app "${name}" - run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \ - run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \ + run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \ + run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \ log info "no containers running" } @@ -552,7 +450,7 @@ cmd_logs() { esac done - run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" + run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available" } cmd_validate_compose() { @@ -560,11 +458,11 @@ cmd_validate_compose() { validate_name "${name}" load_app "${name}" - if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then + if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then log info "compose file is valid" else local output - output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)" + output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)" fail "compose validation failed: ${output}" fi } @@ -575,22 +473,19 @@ cmd_remove() { validate_name "${name}" load_app "${name}" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true # Remove this app's block from the aggregate routes file. local route_file route_file="$(app_route_file)" if [[ -f "${route_file}" ]]; then - routes_lock local tmp tmp="$(mktemp)" sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true install -m 0664 -o reudy -g panelroutes "${tmp}" "${route_file}" - rm -f "${tmp}" - routes_unlock fi - rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")" + rm -f "$(app_manifest "${name}")" rm -rf "${APP_STACK_DIR}" if [[ "${keep_volumes}" != "--keep-volumes" ]]; then @@ -656,10 +551,10 @@ cmd_backup() { # Stop containers before backup for consistency local was_running=false - if run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then + if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then was_running=true log info "stopping containers for consistent backup..." - run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true fi (cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed" @@ -674,7 +569,7 @@ cmd_backup() { # Restart if it was running if [[ "${was_running}" == "true" ]]; then log info "restarting containers after backup..." - run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true fi local size @@ -711,7 +606,7 @@ cmd_volume_clear() { load_app "${name}" log info "clearing volume data for app '${name}'" - run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true local data_dir="${APP_VOLUME_DIR}/data" if [[ -d "${data_dir}" ]]; then @@ -748,7 +643,7 @@ cmd_restore() { # Stop containers before restore log info "stopping containers for restore..." - run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true + run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true # Clear existing volume data and extract backup rm -rf "${volume_dir:?}"/* @@ -776,8 +671,7 @@ cmd_inspect_volumes() { if [[ -n "${podman_bin}" ]]; then "${podman_bin}" volume ls --filter label=com.docker.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true "${podman_bin}" volume ls --filter label=io.podman.compose.project="${name}" --format '{{.Name}}|{{.Mountpoint}}' 2>/dev/null || true - # grep exits 1 when there are no named volumes; that is not an error. - fi | sort -u | grep -v '^$' || true + fi | sort -u | grep -v '^$' } cmd_list() {