panel: Forgejo integration, ssh deploy key and per-app environment variables

Forgejo:
- panel.nix passes the local Forgejo's public, API and ssh URLs (derived
  from forgejo.nix) to panel-api.
- Settings dialog: connect a Forgejo access token (verified against
  /api/v1/user, stored 0600 in state/panel/forgejo-token).
- New-app dialog gets a Forgejo repository picker with search and a branch
  dropdown; private repos are cloned over https with the stored token, or
  over ssh with the deploy key when no token is connected. The app name and
  domain are filled in from the repository name.
- Commit and compare links in the Source tab point at Forgejo; cards show
  the provider ("Forgejo · main").

Git over ssh:
- ssh:// and git@host:owner/repo URLs are accepted; the panel generates an
  ed25519 deploy key in state/panel/ssh and uses it for clone/fetch
  (BatchMode, accept-new host keys). openssh added to the service path.
- Credential redaction only applies to http(s) URLs, so ssh usernames are
  kept; git errors now report the meaningful line instead of git's advice.

Environment variables:
- Stored per app in state/env/<app>.env (0600), outside the repo and stack.
- panelctl passes them to every compose command via env(1), so ${VAR}
  interpolation works; by default deploy/restart also generate a compose
  override listing the keys under every service's environment (values are
  read from compose's environment, never quoted into YAML).
- Environment tab (and a section in the new-app dialog) with .env paste
  import, hidden values, validation of names (reserved podman/compose vars
  rejected), hints for ${VAR}s the compose file uses but aren't set, and
  Save / Save & deploy. Removing an app deletes its variables.

The API still accepts the old source_type "github" / github_* fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf
This commit is contained in:
agent 2026-09-26 22:52:09 +00:00
parent 76a1e09913
commit c1ff6c8176
6 changed files with 1269 additions and 101 deletions

View file

@ -1,5 +1,8 @@
{ config, pkgs, ... }:
{ config, lib, pkgs, ... }:
let
forgejoServer = config.services.forgejo.settings.server;
in
{
environment.systemPackages = [
(pkgs.writeShellScriptBin "panelctl" (builtins.readFile ./panel/panelctl.sh))
@ -34,6 +37,7 @@
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 = {
@ -52,6 +56,12 @@
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}";
};
};

View file

@ -13,6 +13,10 @@ 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
@ -26,7 +30,8 @@ Default bind: `127.0.0.1:9911`
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
| GET | `/apps/<name>/backups` | List available backups |
| GET | `/apps/<name>/backups/<file>` | Download backup zip |
| GET | `/apps/<name>/repo` | Git source info (URL, branch, deployed commit, local changes) |
| GET | `/apps/<name>/env` | Environment variables: `{"vars": [{"key", "value"}], "inject": true}` |
| GET | `/apps/<name>/repo` | Git source info (URL, web URL, provider, branch, deployed commit, local changes, deploy key for ssh) |
| GET | `/apps/<name>/repo?fetch=1` | Same, plus fetches the remote and reports `behind` / `remote` |
| GET | `/apps/<name>/volumes` | Volumes the file browser can open |
| GET | `/apps/<name>/volume/files?vol=&path=` | List a folder in a volume |
@ -50,28 +55,37 @@ Default bind: `127.0.0.1:9911`
| POST | `/apps/<name>/restore` | Restore from backup |
| POST | `/apps/<name>/remove` | Remove app |
| POST | `/apps/<name>/repo-pull` | Git apps: fetch branch, hard-reset checkout to it, redeploy |
| POST | `/apps/<name>/env` | Replace environment variables: `{"vars": [...], "inject": true, "deploy": false}` |
| POST | `/apps/<name>/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)
### 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": "github",
"github_url": "https://git.srazka.com/reudy-net/blog.git",
"github_branch": "",
"github_pat": ""
"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
}
```
Any http(s) git host works. An empty branch uses the repository's default branch.
The compose file must be at the repository root.
- `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`)

View file

@ -114,13 +114,49 @@ panelctl remove whoami
### Git-backed apps
Apps created from a repository (GitHub, Forgejo/Gitea or any https git host)
are cloned to `stacks/<app>/repo`. **Sync** fetches the configured branch and
Apps created from a repository (Forgejo, GitHub or any git host, over https or
ssh) are cloned to `stacks/<app>/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/<app>.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/<app>/.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

View file

@ -450,6 +450,45 @@
}
.segmented button.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
/* ── Repo picker ── */
.picker-list {
margin-top: 6px; max-height: 232px; overflow: auto;
border: 1px solid var(--border); border-radius: 8px;
}
.picker-item {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-top: 1px solid var(--border);
padding: 8px 10px; text-align: left; cursor: pointer; color: var(--text); font: inherit;
}
.picker-item:first-child { border-top: 0; }
.picker-item:hover, .picker-item:focus-visible { background: var(--surface-2); outline: none; }
.picker-item.selected { background: var(--accent-soft); }
.picker-item .icon { color: var(--muted); }
.picker-name { font-weight: 600; font-size: 13.5px; }
.picker-desc { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-main { flex: 1; min-width: 0; }
/* ── Settings ── */
.settings-section + .settings-section { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
.settings-section h3 { font-size: 14px; margin-bottom: 4px; }
.keybox {
margin-top: 10px; display: flex; gap: 8px; align-items: flex-start;
background: var(--surface-2); border-radius: 8px; padding: 10px 12px;
font: 12px/1.5 var(--mono); word-break: break-all;
}
.keybox code { flex: 1; background: none; padding: 0; }
/* ── Environment editor ── */
.env-row { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 28px; gap: 6px; align-items: center; margin-bottom: 6px; }
.env-row .input { height: 32px; padding: 5px 9px; font: 13px var(--mono); }
.env-head { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
.env-details summary { cursor: pointer; list-style: none; }
.env-details summary::-webkit-details-marker { display: none; }
.env-details summary::before { content: "▸"; display: inline-block; width: 14px; color: var(--muted); transition: transform .15s; }
.env-details[open] summary::before { transform: rotate(90deg); }
.missing-vars { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.chip.sm { height: 24px; padding: 0 9px; font: 12px var(--mono); }
/* ── Responsive ── */
@media (max-width: 760px) {
.hide-sm { display: none !important; }
@ -467,6 +506,10 @@
.route-row .arrow { display: none; }
.route-row .route-path { grid-column: 1 / 3; }
.route-head { display: none; }
.env-row { grid-template-columns: minmax(0, 1fr) 28px; }
.env-row > :nth-child(2) { grid-column: 1; }
.env-row > :nth-child(3) { grid-column: 2; grid-row: 1; }
.env-head { display: none; }
.file-row { grid-template-columns: 20px minmax(0, 1fr) auto; }
.file-row > :nth-child(3), .file-row > :nth-child(4) { display: none; }
.logs { height: 320px; }
@ -485,6 +528,7 @@
</label>
<div class="top-actions">
<button id="syncBtn" class="sync" type="button"><span class="sync-dot"></span><span id="syncText">Connecting…</span></button>
<button id="settingsBtn" class="btn ghost icon-only" type="button" title="Settings — Forgejo and deploy key" aria-label="Settings"></button>
<button id="activityBtn" class="btn ghost" type="button" title="Activity — operations and their output">
<span id="activityIcon"></span><span class="hide-sm">Activity</span><span id="activityCount" class="count" hidden></span>
</button>
@ -538,7 +582,7 @@
<div class="segmented" id="naSource">
<button type="button" data-v="default">Starter</button>
<button type="button" data-v="raw">Compose file</button>
<button type="button" data-v="github">Git repository</button>
<button type="button" data-v="git">Git repository</button>
</div>
<p class="hint" id="naSourceHint"></p>
</div>
@ -549,26 +593,58 @@
</div>
<div id="naGit" hidden>
<div class="field" id="naProviderField">
<div class="segmented" id="naProvider">
<button type="button" data-v="forgejo" id="naProviderForgejo">Forgejo</button>
<button type="button" data-v="url">Any git URL</button>
</div>
</div>
<div id="naForgejo">
<div class="field">
<label for="naRepoSearch">Repository</label>
<div class="picker">
<input id="naRepoSearch" class="input" placeholder="Search repositories…" autocomplete="off" spellcheck="false" />
<div class="picker-list" id="naRepoList" role="listbox"></div>
</div>
<p class="hint" id="naForgejoHint"></p>
</div>
<div class="grid-2 tight">
<div class="field">
<label for="naForgejoBranch">Branch</label>
<select id="naForgejoBranch" class="select" style="width:100%" disabled><option value="">Pick a repository first</option></select>
</div>
<div class="field">
<span class="label">Clone with</span>
<p class="muted small" id="naCloneVia" style="padding-top:7px">—</p>
</div>
</div>
</div>
<div id="naUrlMode" hidden>
<div class="field">
<label for="naUrl">Repository URL</label>
<input id="naUrl" class="input" placeholder="https://git.example.com/user/repo" autocomplete="off" spellcheck="false" />
<input id="naUrl" class="input" placeholder="https://github.com/user/repo or ssh://git@host:port/user/repo.git" autocomplete="off" spellcheck="false" />
</div>
<div class="grid-2 tight">
<div class="field">
<label for="naBranch">Branch</label>
<input id="naBranch" class="input" placeholder="default branch" autocomplete="off" spellcheck="false" />
</div>
<div class="field">
<label for="naPat">Access token <span class="muted">(private repos)</span></label>
<div class="field" id="naPatField">
<label for="naPat">Access token <span class="muted">(private https repos)</span></label>
<input id="naPat" class="input" type="password" autocomplete="new-password" />
</div>
</div>
<p class="hint" style="margin:-8px 0 16px">
Works with GitHub, Forgejo/Gitea and other https hosts. Needs a <code>compose.yaml</code> at the repository root.
A token is kept in the clone's git config, so use a read-only one.
<p class="hint" style="margin:-8px 0 12px">
https, <code>ssh://</code> and <code>git@host:owner/repo</code> URLs all work. A token is kept in the clone's git config, so use a read-only one.
</p>
</div>
<div class="notice" id="naDeployKey" hidden></div>
<p class="hint" style="margin:0 0 16px">The repository needs a <code>compose.yaml</code> (or <code>docker-compose.yml</code>) at its root.</p>
</div>
<div class="field">
<span class="label">Routes</span>
<div id="naRoutes"></div>
@ -576,6 +652,11 @@
<p class="hint">Domain → where Caddy forwards requests (the port the container publishes on 127.0.0.1). Path is optional, e.g. <code>/api/*</code>. Wildcard domains need a DNS challenge.</p>
</div>
<details class="field env-details" id="naEnvDetails">
<summary><span class="label" style="display:inline">Environment variables</span> <span class="muted small" id="naEnvCount">optional</span></summary>
<div id="naEnv" style="margin-top:8px"></div>
</details>
<label class="check"><input type="checkbox" id="naAuth" checked /><span><strong>Require login</strong><br><span class="muted small">Put the app behind Authelia.</span></span></label>
<label class="check"><input type="checkbox" id="naDeploy" checked /><span><strong>Deploy right away</strong><br><span class="muted small">Start the containers as soon as the app is created.</span></span></label>
@ -588,6 +669,36 @@
</form>
</dialog>
<dialog id="settingsDlg">
<form id="settingsForm" novalidate>
<header class="dialog-head">
<h2>Settings</h2>
<button type="button" class="btn ghost icon-only" data-close aria-label="Close"></button>
</header>
<div class="dialog-body">
<section class="settings-section" id="setForgejo">
<h3>Forgejo</h3>
<p class="muted small" id="setForgejoStatus">Loading…</p>
<div class="field" id="setTokenField" style="margin-top:10px">
<label for="setToken">Access token</label>
<div style="display:flex;gap:8px">
<input id="setToken" class="input" type="password" autocomplete="new-password" placeholder="Paste a Forgejo access token" />
<button type="submit" class="btn primary" id="setTokenSave">Connect</button>
</div>
<p class="hint">Create one in Forgejo under <em>Settings → Applications</em> with <strong>read</strong> 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.</p>
</div>
<button type="button" class="btn ghost danger sm" id="setTokenRemove" hidden>Disconnect</button>
</section>
<section class="settings-section">
<h3>SSH deploy key</h3>
<p class="muted small">Used for repositories cloned over SSH (e.g. <code id="setSshExample">ssh://git@host/owner/repo.git</code>). Add it as a <strong>read-only deploy key</strong> in the repository's settings, or to your Forgejo account to give the panel access to all your repositories.</p>
<div id="setSshKey" class="muted small">Loading…</div>
</section>
</div>
</form>
</dialog>
<dialog id="confirmDlg" class="sm">
<form method="dialog">
<h2 id="cfTitle"></h2>
@ -658,6 +769,10 @@ const ICONS = {
save: '<path d="M5 3h11l3 3v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M8 3v5h7"/><path d="M8 21v-7h8v7"/>',
logs: '<path d="M4 6h16M4 12h16M4 18h10"/>',
layers: '<path d="M12 3l9 5-9 5-9-5z"/><path d="M3 13l9 5 9-5"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
key: '<circle cx="7.5" cy="15.5" r="4.5"/><path d="M10.7 12.3L21 2"/><path d="M16 7l3 3"/><path d="M19 4l2 2"/>',
external: '<path d="M14 4h6v6"/><path d="M20 4l-9 9"/><path d="M19 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h5"/>',
braces: '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/>',
};
function icon(name, cls = "") {
@ -780,8 +895,44 @@ const api = {
deleteFile: (n, path, vol) => api.request(api.fileUrl(n, path, vol), { method: "DELETE" }),
upload: (n, path, vol, file) => api.request(api.fileUrl(n, path, vol), { method: "PUT", body: file }),
init: (payload) => api.post("/apps/init", payload),
getEnv: (n) => api.request(`/apps/${enc(n)}/env`),
saveEnv: (n, vars, inject) => api.post(`/apps/${enc(n)}/env`, { vars, inject }),
integrations: () => api.request("/integrations"),
saveForgejoToken: (token) => api.post("/integrations/forgejo", { token }),
forgejoRepos: (q) => api.request(`/forgejo/repos?q=${enc(q)}`),
forgejoBranches: (repo) => api.request(`/forgejo/branches?repo=${enc(repo)}`),
};
// Forgejo connection + deploy key, loaded on demand and cached.
const integrations = {
data: null,
pending: null,
load(force = false) {
if (this.data && !force) return Promise.resolve(this.data);
if (!this.pending) {
this.pending = api.integrations()
.then((d) => (this.data = d))
.finally(() => { this.pending = null; });
}
return this.pending;
},
};
const PROVIDER_NAMES = { forgejo: "Forgejo", github: "GitHub", git: "Git" };
function copyText(text, what = "Copied") {
navigator.clipboard?.writeText(text)
.then(() => toast("success", what), () => toast("error", "Couldn't copy to the clipboard"));
}
function keyBox(publicKey) {
return h("div", { class: "keybox" }, h("code", null, publicKey),
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Copy public key", "aria-label": "Copy public key", onclick: () => copyText(publicKey, "Public key copied") }, icon("copy")));
}
const isGitUrl = (u) => /^(https?:\/\/\S+|ssh:\/\/\S+|[\w.-]+@[\w.-]+:\S+)$/.test(u);
const isSshUrl = (u) => !!u && !/^https?:\/\//.test(u);
const session = {
expired: false,
expire() {
@ -811,7 +962,7 @@ const cards = new Map(); // name -> AppCard
const BUSY_LABELS = {
init: "Creating…", deploy: "Deploying…", restart: "Restarting…", stop: "Stopping…",
"repo-pull": "Syncing…", backup: "Backing up…", restore: "Restoring…", remove: "Removing…",
compose: "Saving…", routes: "Updating routes…", "volume-clear": "Clearing…", "render-route": "Rendering…",
compose: "Saving…", routes: "Updating routes…", env: "Saving variables…", "volume-clear": "Clearing…", "render-route": "Rendering…",
};
const busyLabel = (b) => BUSY_LABELS[b] || "Working…";
const busyOf = (app) => state.localBusy.get(app.name) || app.busy || null;
@ -1049,7 +1200,7 @@ window.addEventListener("hashchange", applyHash);
// ─── App card ──────────────────────────────────────────────────────────────
const TABS = [
["overview", "Overview"], ["compose", "Compose"], ["logs", "Logs"], ["routes", "Routes"],
["overview", "Overview"], ["compose", "Compose"], ["env", "Environment"], ["logs", "Logs"], ["routes", "Routes"],
["files", "Files"], ["backups", "Backups"], ["source", "Source"],
];
@ -1094,13 +1245,15 @@ class AppCard {
this.pill.className = `pill pill-${st.key}`;
fill(this.pill, st.key === "busy" ? h("span", { class: "spinner" }) : h("span", { class: "dot" }), st.label);
}
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch]);
const subSig = JSON.stringify([app.routes, app.auth, app.repo_url, app.repo_branch, app.env_count]);
if (subSig !== this.subSig) {
this.subSig = subSig;
const provider = app.repo_provider === "forgejo" || app.repo_provider === "github" ? `${PROVIDER_NAMES[app.repo_provider]} · ` : "";
fill(this.sub,
...domainLinks(app.routes, 2),
app.auth ? h("span", { class: "badge", title: "Visitors must log in through Authelia" }, icon("lock"), "Protected") : null,
app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_url) }, icon("git"), app.repo_branch || "git") : null);
app.repo_url ? h("span", { class: "badge", title: stripUrl(app.repo_web_url || app.repo_url) }, icon("git"), `${provider}${app.repo_branch || "default"}`) : null,
app.env_count ? h("span", { class: "badge hide-sm", title: "Environment variables" }, icon("braces"), `${app.env_count} var${app.env_count === 1 ? "" : "s"}`) : null);
}
this.deployBtn.disabled = st.key === "busy";
if (this.panels) {
@ -1113,6 +1266,7 @@ class AppCard {
this.panels = {
overview: new OverviewPanel(this, "overview"),
compose: new ComposePanel(this, "compose"),
env: new EnvPanel(this, "env"),
logs: new LogsPanel(this, "logs"),
routes: new RoutesPanel(this, "routes"),
files: new FilesPanel(this, "files"),
@ -1178,8 +1332,7 @@ class AppCard {
destroy() {
this.collapse();
this.root.remove();
state.dirty.delete(`${this.name}:compose`);
state.dirty.delete(`${this.name}:routes`);
for (const key of ["compose", "routes", "env"]) state.dirty.delete(`${this.name}:${key}`);
}
}
@ -1200,7 +1353,7 @@ class OverviewPanel extends Panel {
update(app) {
const s = app.status || {};
const containers = s.containers || [];
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state]);
const sig = JSON.stringify([containers, app.routes, app.auth, app.repo_url, app.repo_branch, app.compose_file, s.state, app.env_count, app.env_inject]);
if (sig === this.sig) return;
this.sig = sig;
@ -1224,7 +1377,15 @@ class OverviewPanel extends Panel {
r.path ? h("span", { class: "mono muted" }, r.path) : null))),
h("dl", { class: "facts" },
h("dt", null, "Access"), h("dd", null, app.auth ? "Login required (Authelia)" : "Public"),
h("dt", null, "Source"), h("dd", null, app.repo_url ? `${stripUrl(app.repo_url)} @ ${app.repo_branch || "default"}` : "Compose file"),
h("dt", null, "Source"), h("dd", null, app.repo_url
? [`${PROVIDER_NAMES[app.repo_provider] || "Git"}: `,
app.repo_web_url ? h("a", { href: app.repo_web_url, target: "_blank", rel: "noopener" }, stripUrl(app.repo_web_url)) : stripUrl(app.repo_url),
` @ ${app.repo_branch || "default"}`]
: "Compose file"),
h("dt", null, "Environment"), h("dd", null,
h("button", { type: "button", class: "link", onclick: () => this.card.showTab("env") },
app.env_count ? `${app.env_count} variable${app.env_count === 1 ? "" : "s"}` : "None set"),
app.env_count && !app.env_inject ? h("span", { class: "muted" }, " (compose file only)") : null),
h("dt", null, "Compose"), h("dd", { class: "mono small" }, app.compose_file || "—")));
fill(this.el, h("div", { class: "grid-2" }, containerBox, infoBox));
@ -1504,6 +1665,239 @@ class RoutesPanel extends Panel {
}
}
// ── Environment variables ──
const RESERVED_ENV = new Set(["PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "PWD", "OLDPWD", "IFS", "TERM"]);
const RESERVED_ENV_PREFIXES = ["XDG_", "DBUS_", "DOCKER_", "CONTAINER_", "CONTAINERS_", "COMPOSE_", "PODMAN_", "BUILDAH_", "LD_", "BASH_"];
function envKeyError(key) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return `"${key || "(empty)"}" isn't a valid variable name (letters, digits and _, not starting with a digit).`;
if (RESERVED_ENV.has(key) || RESERVED_ENV_PREFIXES.some((p) => key.startsWith(p))) return `${key} is reserved — it would change how podman/compose run.`;
return null;
}
// Parse KEY=value lines as found in .env files (comments, `export`, quotes).
function parseDotenv(text) {
const out = [];
for (let line of String(text || "").split(/\r?\n/)) {
line = line.trim();
if (!line || line.startsWith("#")) continue;
line = line.replace(/^export\s+/, "");
const i = line.indexOf("=");
if (i < 1) continue;
const key = line.slice(0, i).trim();
let value = line.slice(i + 1).trim();
const q = value[0];
if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) {
value = value.slice(1, -1);
if (q === '"') value = value.replace(/\\(["\\$])/g, "$1");
} else {
const comment = value.search(/\s#/);
if (comment >= 0) value = value.slice(0, comment).trim();
}
out.push({ key, value });
}
return out;
}
// Variables a compose file references: name -> true when every use has a default (${X:-y}).
function composeVarRefs(text) {
const refs = new Map();
const src = String(text || "")
.split("\n").map((l) => l.replace(/(^|\s)#.*$/, "")).join("\n")
.replace(/\$\$/g, "");
for (const m of src.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)(:?[-?+])?[^}]*\}|\$([A-Za-z_][A-Za-z0-9_]*)/g)) {
const name = m[1] || m[3];
const hasDefault = !!m[2] && m[2].endsWith("-");
refs.set(name, (refs.get(name) ?? true) && hasDefault);
}
return refs;
}
function envEditor({ onChange = () => {} } = {}) {
let reveal = false;
const rows = h("div");
const revealToggle = h("input", {
type: "checkbox",
onchange: () => {
reveal = revealToggle.checked;
for (const r of rows.children) r._inputs.v.type = reveal ? "text" : "password";
},
});
const pasteArea = h("textarea", { class: "code", rows: "6", spellcheck: "false", style: "min-height:120px", placeholder: "KEY=value\n# comments, export and quotes are fine\nDATABASE_URL=postgres://…" });
const pasteBox = h("div", { hidden: true, style: "margin:8px 0" }, pasteArea,
h("div", { class: "row-actions" },
btn("Import", "check", () => { importText(pasteArea.value); pasteArea.value = ""; pasteBox.hidden = true; }, "primary"),
btn("Cancel", null, () => { pasteBox.hidden = true; }, "ghost")));
const empty = h("p", { class: "muted small", style: "margin:4px 0 8px" }, "No variables yet.");
const el = h("div", null,
h("div", { class: "env-row env-head" }, h("span", null, "Name"), h("span", null, "Value"), h("span")),
empty,
rows,
h("div", { class: "row-actions" },
btn("Add variable", "plus", () => { addRow({}, true); changed(); }, "ghost"),
btn("Paste .env", "file", () => { pasteBox.hidden = false; pasteArea.focus(); }, "ghost"),
h("label", { class: "toggle" }, revealToggle, "Show values")),
pasteBox);
function changed() {
for (const r of rows.children) { r._inputs.k.classList.remove("invalid"); r._inputs.v.classList.remove("invalid"); }
empty.hidden = rows.children.length > 0;
onChange();
}
function addRow(v = {}, focus = false) {
const k = h("input", {
class: "input", placeholder: "NAME", value: v.key || "", spellcheck: "false", autocomplete: "off", "aria-label": "Variable name",
oninput: (e) => {
const cleaned = e.target.value.replace(/[^A-Za-z0-9_]/g, "_");
if (cleaned !== e.target.value) e.target.value = cleaned;
changed();
},
});
const val = h("input", {
class: "input", type: reveal ? "text" : "password", placeholder: "value", value: v.value || "",
spellcheck: "false", autocomplete: "new-password", "aria-label": "Value", oninput: changed,
onpaste: (e) => {
// Pasting several KEY=value lines into a value field imports them all.
const text = e.clipboardData?.getData("text") || "";
if (text.includes("\n") && parseDotenv(text).length > 1) { e.preventDefault(); row.remove(); importText(text); }
},
});
const row = h("div", { class: "env-row" }, k, val,
h("button", { type: "button", class: "btn ghost sm icon-only", title: "Remove", "aria-label": "Remove variable", onclick: () => { row.remove(); changed(); } }, icon("x")));
row._inputs = { k, v: val };
rows.append(row);
empty.hidden = true;
if (focus) k.focus();
return row;
}
function importText(text) {
const parsed = parseDotenv(text);
for (const { key, value } of parsed) {
const existing = [...rows.children].find((r) => r._inputs.k.value === key);
if (existing) existing._inputs.v.value = value; else addRow({ key, value });
}
for (const r of [...rows.children]) if (!r._inputs.k.value && !r._inputs.v.value) r.remove();
changed();
toast(parsed.length ? "success" : "info", parsed.length ? `Imported ${parsed.length} variable${parsed.length === 1 ? "" : "s"}` : "No KEY=value lines found");
}
return {
el,
set(vars) { fill(rows); vars.forEach((v) => addRow(v)); empty.hidden = vars.length > 0; },
get() {
return [...rows.children]
.map((r) => ({ key: r._inputs.k.value.trim(), value: r._inputs.v.value }))
.filter((v) => v.key || v.value);
},
keys() { return new Set(this.get().map((v) => v.key)); },
add(key) { const r = addRow({ key }); r._inputs.v.focus(); changed(); },
validate() {
const seen = new Set();
for (const r of rows.children) {
const { k, v } = r._inputs;
const key = k.value.trim();
if (!key && !v.value) continue;
const err = envKeyError(key) || (seen.has(key) ? `${key} is set more than once.` : null);
if (err) { k.classList.add("invalid"); k.focus(); return err; }
seen.add(key);
}
return null;
},
};
}
class EnvPanel extends Panel {
constructor(card, key) {
super(card, key);
this.loaded = false;
this.original = "";
this.dirty = false;
this.refs = null;
this.editor = envEditor({ onChange: () => this.onChange() });
this.inject = h("input", { type: "checkbox", checked: true, onchange: () => this.onChange() });
this.missing = h("div", { class: "notice warn", hidden: true });
this.err = h("p", { class: "form-error", hidden: true });
this.dirtyLabel = h("span", { class: "dirty-label", hidden: true }, "Unsaved changes");
this.el.append(
h("p", { class: "muted small", style: "margin-bottom:10px" },
"Available as ", h("code", null, "${NAME}"), " in the compose file and applied on the next deploy. ",
"Stored on the server outside the repository, readable only by the panel."),
this.missing,
this.editor.el,
h("label", { class: "check" }, this.inject,
h("span", null, h("strong", null, "Pass to every container"), h("br"),
h("span", { class: "muted small" }, "Otherwise they are only used to fill in ${…} references in the compose file."))),
this.err,
h("div", { class: "row-actions" },
btn("Save & deploy", "play", () => this.save(true), "primary"),
btn("Save", "save", () => this.save(false)),
btn("Revert", null, () => this.revert(), "ghost"),
h("span", { class: "spacer" }),
this.dirtyLabel));
}
show() {
if (!this.loaded) this.load();
this.checkMissing();
}
sig() { return JSON.stringify([this.editor.get(), this.inject.checked]); }
async load() {
try {
const d = await api.getEnv(this.name);
this.editor.set(d.vars || []);
this.inject.checked = d.inject !== false;
this.loaded = true;
this.original = this.sig();
this.onChange();
} catch (e) {
this.err.textContent = `Couldn't load variables: ${e.message}`;
this.err.hidden = false;
}
}
onChange() {
this.dirty = this.loaded && this.sig() !== this.original;
this.dirtyLabel.hidden = !this.dirty;
this.err.hidden = true;
this.card.setDirty("env", this.dirty);
this.renderMissing();
}
async checkMissing() {
try {
this.refs = composeVarRefs((await api.getCompose(this.name)).content);
this.renderMissing();
} catch {
// Hints only.
}
}
renderMissing() {
if (!this.refs || !this.loaded) return;
const keys = this.editor.keys();
const missing = [...this.refs].filter(([k, hasDefault]) => !hasDefault && !keys.has(k)).map(([k]) => k);
this.missing.hidden = !missing.length;
if (missing.length) {
fill(this.missing, icon("alert"), h("div", { class: "missing-vars" },
h("span", null, "The compose file uses variables that aren't set:"),
missing.map((k) => h("button", { type: "button", class: "chip sm", title: `Add ${k}`, onclick: () => this.editor.add(k) }, `+ ${k}`))));
}
}
async revert() {
this.loaded = false;
await this.load();
}
async save(deploy) {
const error = this.editor.validate();
if (error) { this.err.textContent = error; this.err.hidden = false; return; }
const vars = this.editor.get();
const res = await runOp(this.name, "env", `Save variables · ${this.name}`,
() => api.saveEnv(this.name, vars, this.inject.checked),
{ success: deploy ? null : "Variables saved", successDetail: () => "They apply on the next deploy." });
if (!res) return;
this.original = this.sig();
this.onChange();
if (deploy) actions.deploy(this.name);
}
}
// ── Files ──
const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes("Files");
@ -1696,9 +2090,11 @@ class BackupsPanel extends Panel {
// ── Git source ──
function commitView(c) {
function commitView(c, webUrl = "") {
const sha = h("code", null, c.short);
return h("span", { class: "commit" },
h("code", null, c.short), h("span", null, c.subject),
webUrl ? h("a", { href: `${webUrl}/commit/${c.sha}`, target: "_blank", rel: "noopener", title: "Open commit" }, sha) : sha,
h("span", null, c.subject),
h("span", { class: "muted small" }, ` — ${c.author}${c.time ? `, ${ago(c.time * 1000)}` : ""}`));
}
@ -1729,14 +2125,22 @@ class SourcePanel extends Panel {
}
}
render(d, fetched) {
const url = /^https?:\/\//.test(d.url) ? d.url : null;
const web = d.web_url || "";
const provider = PROVIDER_NAMES[d.provider] || "Git";
const authFailed = /permission denied|publickey|authentication failed|could not read username|403|401/i.test(d.fetch_error || "");
fill(this.info,
h("dl", { class: "facts" },
h("dt", null, "Repository"), h("dd", null, url ? h("a", { href: url, target: "_blank", rel: "noopener" }, stripUrl(url)) : d.url),
h("dt", null, "Repository"), h("dd", null,
web ? h("a", { href: web, target: "_blank", rel: "noopener" }, stripUrl(web)) : d.url,
h("span", { class: "muted" }, ` · ${provider}${d.ssh ? " over SSH" : ""}`)),
h("dt", null, "Branch"), h("dd", { class: "mono" }, d.branch || "default"),
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit) : h("span", { class: "muted" }, "Not cloned yet — sync to clone it."))),
h("dt", null, "Deployed commit"), h("dd", null, d.commit ? commitView(d.commit, web) : h("span", { class: "muted" }, "Not cloned yet — sync to clone it."))),
d.dirty ? h("div", { class: "notice warn", style: "margin-top:12px" }, icon("alert"),
h("span", null, "The checkout has local changes (for example compose edits made here). They will be discarded on the next sync.")) : null);
h("span", null, "The checkout has local changes (for example compose edits made here). They will be discarded on the next sync.")) : null,
d.ssh && d.public_key ? h("details", { class: "env-details", style: "margin-top:12px", open: authFailed },
h("summary", null, h("span", { class: "label", style: "display:inline" }, "Deploy key"),
h("span", { class: "muted small" }, " — add it to the repository if syncing fails with “permission denied”")),
keyBox(d.public_key)) : null);
if (!fetched) return;
this.check.hidden = false;
if (d.fetch_error) {
@ -1747,7 +2151,8 @@ class SourcePanel extends Panel {
const n = d.behind;
fill(this.check,
h("strong", null, n == null ? "New commits available" : `${n} new commit${n === 1 ? "" : "s"}`),
d.remote ? h("span", null, " — latest: ", commitView(d.remote)) : null);
d.remote ? h("span", null, " — latest: ", commitView(d.remote, web)) : null,
web && d.commit && d.remote ? h("a", { href: `${web}/compare/${d.commit.sha}...${d.remote.sha}`, target: "_blank", rel: "noopener" }, icon("external"), " Compare") : null);
}
}
}
@ -2041,23 +2446,35 @@ const composeTemplate = (port) => `services:
const SOURCE_HINTS = {
default: "Starts a tiny traefik/whoami container so you can check the route works. Edit the compose file afterwards.",
raw: "Paste a compose file. Publish the app's port on 127.0.0.1 so Caddy can reach it.",
github: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
git: "Clones a repository and deploys its compose file. Use “Sync” later to pull new commits and redeploy.",
};
const slugify = (s) => String(s || "").toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
const newApp = {
source: "default",
provider: "forgejo",
domainTouched: false,
selectedRepo: null,
searchTimer: null,
searchSeq: 0,
init() {
this.dlg = $("#newAppDlg");
this.routes = routeEditor({ onChange: () => { $("#naError").hidden = true; } });
$("#naRoutes").append(this.routes.el);
this.env = envEditor({ onChange: () => this.updateEnvCount() });
$("#naEnv").append(this.env.el);
$("#naAddRoute").append(icon("plus"), "Add route");
$("#naAddRoute").onclick = () => this.routes.add();
$("#naSource").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setSource(b.dataset.v);
};
$("#naProvider").onclick = (e) => {
const b = e.target.closest("button[data-v]");
if (b) this.setProvider(b.dataset.v);
};
$("#naName").addEventListener("input", (e) => {
const v = e.target.value.toLowerCase().replace(/[\s_.]+/g, "-").replace(/[^a-z0-9-]/g, "");
if (v !== e.target.value) e.target.value = v;
@ -2067,6 +2484,20 @@ const newApp = {
this.routes.el.addEventListener("input", (e) => {
if (e.target === this.routes.first()?.d) this.domainTouched = true;
});
$("#naRepoSearch").addEventListener("input", () => {
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => this.searchRepos($("#naRepoSearch").value.trim()), 250);
});
$("#naRepoSearch").addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") { e.preventDefault(); $("#naRepoList .picker-item")?.focus(); }
});
$("#naRepoList").addEventListener("keydown", (e) => {
const items = [...$("#naRepoList").querySelectorAll(".picker-item")];
const i = items.indexOf(document.activeElement);
if (e.key === "ArrowDown" && i < items.length - 1) { e.preventDefault(); items[i + 1].focus(); }
if (e.key === "ArrowUp") { e.preventDefault(); (i > 0 ? items[i - 1] : $("#naRepoSearch")).focus(); }
});
$("#naUrl").addEventListener("input", () => this.updateUrlMode());
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
if (!b.textContent.trim()) b.append(icon("x"));
b.onclick = () => this.dlg.close();
@ -2078,7 +2509,12 @@ const newApp = {
$("#newAppForm").reset();
$("#naError").hidden = true;
$("#naCompose").value = "";
$("#naEnvDetails").open = false;
this.env.set([]);
this.updateEnvCount();
this.domainTouched = false;
this.selectedRepo = null;
this.reposLoaded = false;
this.port = suggestPort();
this.base = suggestBaseDomain();
this.routes.set([{ domain: "", upstream: `127.0.0.1:${this.port}` }]);
@ -2088,6 +2524,11 @@ const newApp = {
$("#naName").focus();
},
updateEnvCount() {
const n = this.env.get().length;
$("#naEnvCount").textContent = n ? `${n} set` : "optional";
},
autofill() {
const first = this.routes.first();
if (!first || this.domainTouched) return;
@ -2099,12 +2540,129 @@ const newApp = {
this.source = v;
for (const b of $("#naSource").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
$("#naRaw").hidden = v !== "raw";
$("#naGit").hidden = v !== "github";
$("#naGit").hidden = v !== "git";
$("#naSourceHint").textContent = SOURCE_HINTS[v];
if (v === "raw" && !$("#naCompose").value.trim()) {
const port = (this.routes.first()?.u.value || "").split(":").pop() || this.port;
$("#naCompose").value = composeTemplate(port);
}
if (v === "git") this.initGit();
},
async initGit() {
let d = null;
try { d = await integrations.load(); } catch { /* fall back to URL mode */ }
const configured = !!d?.forgejo?.configured;
$("#naProviderField").hidden = !configured;
if (configured) $("#naProviderForgejo").textContent = `Forgejo · ${stripUrl(d.forgejo.url)}`;
this.setProvider(configured ? this.provider : "url");
},
setProvider(v) {
this.provider = v;
for (const b of $("#naProvider").querySelectorAll("button")) b.classList.toggle("active", b.dataset.v === v);
$("#naForgejo").hidden = v !== "forgejo";
$("#naUrlMode").hidden = v !== "url";
if (v === "forgejo" && !this.reposLoaded) this.searchRepos("");
if (v === "forgejo") this.updateCloneVia(); else this.updateUrlMode();
},
async searchRepos(q) {
const seq = ++this.searchSeq;
const list = $("#naRepoList");
fill(list, h("div", { class: "empty-sm" }, h("span", { class: "spinner" }), " Loading repositories…"));
try {
const d = await api.forgejoRepos(q);
if (seq !== this.searchSeq) return;
this.reposLoaded = true;
const f = integrations.data?.forgejo;
fill($("#naForgejoHint"), d.authenticated ? null : [
"Showing public repositories only. ",
h("button", { type: "button", class: "link", onclick: () => settings.open() }, "Connect a Forgejo token"),
" to see private ones — or use “Any git URL” with an SSH URL and the deploy key."]);
const repos = d.repos.filter((r) => !r.archived);
if (!repos.length) {
fill(list, h("div", { class: "empty-sm" }, q ? `No repositories match “${q}”.` : "No repositories found."));
return;
}
fill(list, repos.map((r) => h("button", {
type: "button", role: "option",
class: `picker-item${this.selectedRepo?.full_name === r.full_name ? " selected" : ""}`,
onclick: () => this.selectRepo(r),
},
icon(r.private ? "lock" : "git"),
h("span", { class: "picker-main" },
h("div", { class: "picker-name" }, r.full_name, r.empty ? h("span", { class: "muted small" }, " (empty)") : null),
r.description ? h("div", { class: "picker-desc" }, r.description) : null),
h("span", { class: "muted small hide-sm" }, r.updated_at ? ago(Date.parse(r.updated_at)) : ""))));
if (f && !f.has_token && d.repos.length === 0 && !q) {
fill(list, h("div", { class: "empty-sm" }, "No public repositories. Connect a token in Settings to list private ones."));
}
} catch (e) {
if (seq !== this.searchSeq) return;
fill(list, h("div", { class: "empty-sm error" }, `Couldn't load repositories: ${e.message}`));
}
},
async selectRepo(r) {
this.selectedRepo = r;
$("#naError").hidden = true;
for (const item of $("#naRepoList").querySelectorAll(".picker-item")) {
item.classList.toggle("selected", item.querySelector(".picker-name")?.firstChild?.textContent === r.full_name);
}
if (!$("#naName").value) {
$("#naName").value = slugify(r.full_name.split("/")[1]);
this.autofill();
}
this.updateCloneVia();
const sel = $("#naForgejoBranch");
sel.disabled = true;
fill(sel, h("option", { value: "" }, "Loading branches…"));
try {
const d = await api.forgejoBranches(r.full_name);
if (this.selectedRepo !== r) return;
const branches = d.branches.length ? d.branches : [r.default_branch || "main"];
fill(sel, branches.map((b) => h("option", { value: b }, b === r.default_branch ? `${b} (default)` : b)));
sel.value = branches.includes(r.default_branch) ? r.default_branch : branches[0];
sel.disabled = false;
} catch (e) {
fill(sel, h("option", { value: r.default_branch || "" }, r.default_branch || "default branch"));
sel.disabled = false;
}
},
// How a Forgejo repository will be cloned, given the panel's credentials.
cloneVia(r) {
const f = integrations.data?.forgejo;
if (f?.has_token) return { url: r.clone_url, useToken: true, label: "HTTPS with your Forgejo token" };
if (r.private) return { url: r.ssh_url, useToken: false, ssh: true, label: "SSH with the panel's deploy key" };
return { url: r.clone_url, useToken: false, label: "HTTPS (public repository)" };
},
updateCloneVia() {
const r = this.selectedRepo;
const via = r ? this.cloneVia(r) : null;
$("#naCloneVia").textContent = via ? via.label : "—";
this.showDeployKey(!!via?.ssh);
},
updateUrlMode() {
const url = $("#naUrl").value.trim();
const ssh = !!url && isSshUrl(url);
$("#naPatField").hidden = ssh;
this.showDeployKey(ssh);
},
async showDeployKey(show) {
const box = $("#naDeployKey");
if (!show || this.source !== "git") { box.hidden = true; return; }
let key = null;
try { key = (await integrations.load()).ssh?.public_key; } catch { /* shown below */ }
box.hidden = false;
fill(box, icon("key"), h("div", { style: "flex:1;min-width:0" },
h("div", null, "Cloning over SSH uses the panel's deploy key. Add it to the repository as a ",
h("strong", null, "read-only deploy key"), " (Repository settings → Deploy keys) before creating the app."),
key ? keyBox(key) : h("div", { class: "error" }, "No deploy key available (ssh-keygen missing).")));
},
async submit() {
@ -2120,30 +2678,48 @@ const newApp = {
if (state.apps.has(name)) return fail(`An app called “${name}” already exists.`, $("#naName"));
const routeError = this.routes.validate();
if (routeError) return fail(routeError);
const envError = this.env.validate();
if (envError) { $("#naEnvDetails").open = true; return fail(envError); }
const payload = { name, routes: normRoutes(this.routes.get()), auth: $("#naAuth").checked, source_type: this.source };
const payload = {
name,
routes: normRoutes(this.routes.get()),
auth: $("#naAuth").checked,
source_type: this.source,
env: this.env.get(),
};
if (this.source === "raw") {
payload.compose_content = $("#naCompose").value;
if (!payload.compose_content.trim()) return fail("Paste a compose file.", $("#naCompose"));
}
if (this.source === "github") {
payload.github_url = $("#naUrl").value.trim();
payload.github_branch = $("#naBranch").value.trim();
payload.github_pat = $("#naPat").value.trim();
if (!/^https?:\/\/\S+$/.test(payload.github_url)) return fail("Enter the repository's https:// URL.", $("#naUrl"));
if (this.source === "git") {
if (this.provider === "forgejo") {
const r = this.selectedRepo;
if (!r) return fail("Pick a repository.", $("#naRepoSearch"));
const via = this.cloneVia(r);
payload.repo_url = via.url;
payload.repo_branch = $("#naForgejoBranch").value;
payload.use_forgejo_token = via.useToken;
} else {
payload.repo_url = $("#naUrl").value.trim();
payload.repo_branch = $("#naBranch").value.trim();
payload.repo_token = isSshUrl(payload.repo_url) ? "" : $("#naPat").value.trim();
if (!isGitUrl(payload.repo_url)) return fail("Enter an https://, ssh:// or git@host:owner/repo URL.", $("#naUrl"));
}
}
const deploy = $("#naDeploy").checked;
const submitBtn = $("#naSubmit");
submitBtn.disabled = true;
fill(submitBtn, h("span", { class: "spinner" }), this.source === "github" ? "Cloning…" : "Creating…");
fill(submitBtn, h("span", { class: "spinner" }), this.source === "git" ? "Cloning…" : "Creating…");
const entry = activity.start(`Create · ${name}`);
try {
const res = await api.init(payload);
entry.finish(true, outputOf(res));
} catch (e) {
entry.finish(false, e.detail || e.message);
return fail(e.message);
const hint = /permission denied|publickey/i.test(e.detail || e.message) ? " — has the deploy key been added to the repository?" : "";
return fail(e.message + hint);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = "Create app";
@ -2161,6 +2737,85 @@ const newApp = {
},
};
// ─── Settings dialog ───────────────────────────────────────────────────────
const settings = {
init() {
this.dlg = $("#settingsDlg");
this.dlg.querySelectorAll("[data-close]").forEach((b) => {
b.append(icon("x"));
b.onclick = () => this.dlg.close();
});
$("#settingsForm").onsubmit = (e) => { e.preventDefault(); this.saveToken(); };
$("#setTokenRemove").onclick = () => this.removeToken();
},
async open() {
if (!this.dlg.open) this.dlg.showModal();
await this.render(true);
},
async render(force) {
let d;
try {
d = await integrations.load(force);
} catch (e) {
$("#setForgejoStatus").textContent = `Couldn't load settings: ${e.message}`;
return;
}
const f = d.forgejo;
$("#setForgejo").hidden = !f.configured;
if (f.configured) {
const link = h("a", { href: f.url, target: "_blank", rel: "noopener" }, stripUrl(f.url));
fill($("#setForgejoStatus"), f.has_token
? (f.user
? [icon("check"), " Connected to ", link, " as ", h("strong", null, f.user), "."]
: ["A token is saved, but it doesn't work: ", f.error || "unknown error", ". Paste a new one below."])
: ["Instance: ", link, ". Not connected — the panel can only list public repositories."]);
$("#setTokenField").hidden = !!(f.has_token && f.user);
$("#setTokenRemove").hidden = !f.has_token;
$("#setSshExample").textContent = f.ssh_url ? `${f.ssh_url}/owner/repo.git` : "ssh://git@host/owner/repo.git";
}
fill($("#setSshKey"), d.ssh?.public_key
? keyBox(d.ssh.public_key)
: h("span", { class: "error" }, "ssh-keygen isn't available, so cloning over SSH is disabled."));
},
async saveToken() {
const token = $("#setToken").value.trim();
if (!token) { $("#setToken").focus(); return; }
const b = $("#setTokenSave");
b.disabled = true;
try {
const r = await api.saveForgejoToken(token);
$("#setToken").value = "";
toast("success", `Connected to Forgejo as ${r.user}`);
await this.render(true);
newApp.reposLoaded = false;
if (newApp.dlg.open && newApp.source === "git" && newApp.provider === "forgejo") {
newApp.searchRepos($("#naRepoSearch").value.trim());
newApp.updateCloneVia();
}
} catch (e) {
toast("error", "Couldn't connect to Forgejo", { detail: e.message });
} finally {
b.disabled = false;
}
},
async removeToken() {
const ok = await confirmDialog({
title: "Disconnect Forgejo?",
body: [h("p", null, "The stored token is deleted. Apps already cloned with it keep syncing — the token stays in their git config.")],
confirmLabel: "Disconnect", danger: true,
});
if (!ok) return;
try {
await api.saveForgejoToken("");
toast("success", "Forgejo disconnected");
} catch (e) {
toast("error", "Couldn't disconnect", { detail: e.message });
}
this.render(true);
},
};
// ─── Wiring ────────────────────────────────────────────────────────────────
$("#brandMark").append(icon("layers"));
@ -2170,6 +2825,8 @@ $("#newIcon").append(icon("plus"));
$("#activityClose").append(icon("x"));
$("#newAppBtn").onclick = () => newApp.open();
$("#settingsBtn").append(icon("settings"));
$("#settingsBtn").onclick = () => settings.open();
$("#syncBtn").onclick = () => (session.expired ? location.reload() : poller.now());
$("#reloginBtn").onclick = () => location.reload();
$("#activityBtn").onclick = () => activity.toggle();
@ -2211,6 +2868,7 @@ setInterval(() => {
}, 1000);
newApp.init();
settings.init();
renderFilters();
renderSync();
poller.schedule(0);

View file

@ -4,10 +4,14 @@
import json
import os
import re
import shlex
import shutil
import socket
import subprocess
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -22,12 +26,38 @@ 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.
REPO_URL_RE = re.compile(r"^https?://[^\s\"'`$\\]+$")
_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):
@ -67,8 +97,9 @@ def busy_snapshot():
def redact_credentials(text, replacement="***@"):
"""Hide user:token@ credentials embedded in URLs."""
return re.sub(r"([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]+@", r"\1" + replacement, text or "")
"""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):
@ -76,17 +107,74 @@ def last_line(text):
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
# Never block on an interactive credential prompt.
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
try:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=timeout)
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 {
@ -98,14 +186,145 @@ def run_git(args, cwd=None, timeout=GIT_TIMEOUT):
def clone_repo(url, branch, target_dir, token=""):
auth_url = url
if token:
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/<app>.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"]:
@ -200,13 +419,18 @@ def load_app_summaries():
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": redact_credentials(env.get("APP_REPO_URL", ""), ""),
"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
@ -456,6 +680,76 @@ 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/<name>/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":
@ -625,9 +919,14 @@ class Handler(BaseHTTPRequestHandler):
"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)
@ -636,7 +935,7 @@ class Handler(BaseHTTPRequestHandler):
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"] = last_line(fetched["stderr"]) or "git fetch failed"
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)
@ -921,21 +1220,33 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return
if source_type not in ["default", "raw", "github"]:
if source_type == "github": # older clients
source_type = "git"
if source_type not in ["default", "raw", "git"]:
self._json(400, {"ok": False, "error": "invalid source_type"})
return
# Validate git parameters before creating anything.
if source_type == "github":
repo_url = str(payload.get("github_url", "")).strip()
branch = str(payload.get("github_branch", "")).strip()
token = str(payload.get("github_pat", "")).strip()
if not REPO_URL_RE.match(repo_url):
self._json(400, {"ok": False, "error": "repository URL must be a plain http(s) URL"})
# 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])
@ -960,9 +1271,11 @@ class Handler(BaseHTTPRequestHandler):
self._json(500, {"ok": False, "error": f"failed to write compose: {exc}"})
return
elif source_type == "github":
# Any http(s) git host works (GitHub, Forgejo, ...). A token is
# embedded in the clone URL, so later syncs reuse it from .git/config.
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.
target_dir = os.path.join(app["APP_STACK_DIR"], "repo")
if os.path.exists(target_dir):
shutil.rmtree(target_dir)
@ -972,7 +1285,7 @@ class Handler(BaseHTTPRequestHandler):
run_panelctl(["remove", name])
self._json(400, {
"ok": False,
"error": f"git clone failed: {last_line(cloned['stderr'])}",
"error": f"git clone failed: {git_error(cloned['stderr'])}",
"stderr": cloned["stderr"],
})
return
@ -997,19 +1310,78 @@ class Handler(BaseHTTPRequestHandler):
commit = repo_commit(target_dir)
summary = f"cloned {branch} at {commit['short']}: {commit['subject']}" if commit else "cloned"
self._json(200, {"ok": True, "code": 0, "stdout": summary})
return
self._json(200, {"ok": True, "code": 0, "stdout": "initialized successfully"})
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})
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/<name>/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/<name>/compose — save compose file
if action == "compose":
app, err = read_app_info(name)
@ -1141,7 +1513,7 @@ class Handler(BaseHTTPRequestHandler):
if not fetched["ok"]:
self._json(400, {
"ok": False,
"error": f"git fetch failed: {last_line(fetched['stderr'])}",
"error": f"git fetch failed: {git_error(fetched['stderr'])}",
"stderr": fetched["stderr"],
})
return
@ -1149,7 +1521,7 @@ class Handler(BaseHTTPRequestHandler):
if not reset["ok"]:
self._json(400, {
"ok": False,
"error": f"git reset failed: {last_line(reset['stderr'])}",
"error": f"git reset failed: {git_error(reset['stderr'])}",
"stderr": reset["stderr"],
})
return
@ -1162,7 +1534,7 @@ class Handler(BaseHTTPRequestHandler):
if not cloned["ok"]:
self._json(400, {
"ok": False,
"error": f"git clone failed: {last_line(cloned['stderr'])}",
"error": f"git clone failed: {git_error(cloned['stderr'])}",
"stderr": cloned["stderr"],
})
return

View file

@ -7,8 +7,15 @@ 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
@ -186,6 +193,75 @@ 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() {
@ -252,11 +328,11 @@ run_compose() {
if [[ "${compose}" == *" compose" ]]; then
local podman_bin="${compose% compose}"
"${podman_bin}" compose "$@"
env "${APP_ENV_ARGS[@]}" "${podman_bin}" compose "$@"
return
fi
"${compose}" "$@"
env "${APP_ENV_ARGS[@]}" "${compose}" "$@"
}
write_default_compose() {
@ -404,11 +480,12 @@ 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 -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1)"; then
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
log err "Deployment failed for app '${name}'"
@ -427,9 +504,10 @@ cmd_restart() {
log info "Restarting app '${name}'"
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
prepare_env_override "${name}"
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d --build --remove-orphans 2>&1; then
if ! run_compose "${COMPOSE_ARGS[@]}" up -d --build --remove-orphans 2>&1; then
fail "compose up failed during restart"
fi
@ -441,7 +519,7 @@ cmd_stop() {
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans || fail "compose down failed"
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans || fail "compose down failed"
log info "stopped app '${name}'"
}
@ -450,8 +528,8 @@ cmd_status() {
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \
run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \
run_compose "${COMPOSE_ARGS[@]}" ps --format json 2>/dev/null || \
run_compose "${COMPOSE_ARGS[@]}" ps 2>/dev/null || \
log info "no containers running"
}
@ -474,7 +552,7 @@ cmd_logs() {
esac
done
run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
run_compose "${COMPOSE_ARGS[@]}" logs --tail "${tail_lines}" 2>&1 || log info "no logs available"
}
cmd_validate_compose() {
@ -482,11 +560,11 @@ cmd_validate_compose() {
validate_name "${name}"
load_app "${name}"
if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then
if run_compose "${COMPOSE_ARGS[@]}" config >/dev/null 2>&1; then
log info "compose file is valid"
else
local output
output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)"
output="$(run_compose "${COMPOSE_ARGS[@]}" config 2>&1 || true)"
fail "compose validation failed: ${output}"
fi
}
@ -497,7 +575,7 @@ cmd_remove() {
validate_name "${name}"
load_app "${name}"
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
# Remove this app's block from the aggregate routes file.
local route_file
@ -512,7 +590,7 @@ cmd_remove() {
routes_unlock
fi
rm -f "$(app_manifest "${name}")"
rm -f "$(app_manifest "${name}")" "$(app_env_file "${name}")"
rm -rf "${APP_STACK_DIR}"
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
@ -578,10 +656,10 @@ cmd_backup() {
# Stop containers before backup for consistency
local was_running=false
if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
if run_compose "${COMPOSE_ARGS[@]}" 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 -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
fi
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
@ -596,7 +674,7 @@ cmd_backup() {
# Restart if it was running
if [[ "${was_running}" == "true" ]]; then
log info "restarting containers after backup..."
run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true
run_compose "${COMPOSE_ARGS[@]}" up -d 2>/dev/null || true
fi
local size
@ -633,7 +711,7 @@ cmd_volume_clear() {
load_app "${name}"
log info "clearing volume data for app '${name}'"
run_compose -f "${APP_COMPOSE_FILE}" down --remove-orphans 2>/dev/null || true
run_compose "${COMPOSE_ARGS[@]}" down --remove-orphans 2>/dev/null || true
local data_dir="${APP_VOLUME_DIR}/data"
if [[ -d "${data_dir}" ]]; then
@ -670,7 +748,7 @@ cmd_restore() {
# Stop containers before restore
log info "stopping containers for restore..."
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
run_compose "${COMPOSE_ARGS[@]}" down 2>/dev/null || true
# Clear existing volume data and extract backup
rm -rf "${volume_dir:?}"/*