From db46c5e793bf365afe566aaa2b5fb53effdf3efc Mon Sep 17 00:00:00 2001 From: agent Date: Sat, 26 Sep 2026 22:52:09 +0000 Subject: [PATCH] panel: Forgejo integration, ssh deploy key and per-app environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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 Claude-Session: https://claude.ai/code/session_01UbWSNkXxZhYf7eqHTyx3Bf --- API.md | 30 +- README.md | 40 ++- frontend/index.html | 748 +++++++++++++++++++++++++++++++++++++++++--- panel-api.py | 426 +++++++++++++++++++++++-- panelctl.sh | 114 +++++-- 5 files changed, 1258 insertions(+), 100 deletions(-) diff --git a/API.md b/API.md index 87787a3..298faea 100644 --- a/API.md +++ b/API.md @@ -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//logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps//backups` | List available backups | | GET | `/apps//backups/` | Download backup zip | -| GET | `/apps//repo` | Git source info (URL, branch, deployed commit, local changes) | +| 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 | @@ -50,28 +55,37 @@ Default bind: `127.0.0.1:9911` | 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) +### 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`) diff --git a/README.md b/README.md index cf6dc4f..5f9cc49 100644 --- a/README.md +++ b/README.md @@ -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//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//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 diff --git a/frontend/index.html b/frontend/index.html index a7dfc4c..298ffb1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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 @@
+ @@ -538,7 +582,7 @@
- +

@@ -549,24 +593,56 @@