Add panel-api and panelctl scripts for container management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
commit
8986399a92
4 changed files with 930 additions and 0 deletions
48
API.md
Normal file
48
API.md
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# panel-api
|
||||||
|
|
||||||
|
A tiny local HTTP API wrapper around panelctl for future frontend integration.
|
||||||
|
|
||||||
|
The service now also serves a lightweight web UI at `/`.
|
||||||
|
|
||||||
|
Default bind:
|
||||||
|
- 127.0.0.1:9911
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- GET /health
|
||||||
|
- GET /
|
||||||
|
- GET /apps
|
||||||
|
- GET /apps/<name>
|
||||||
|
- POST /apps/init
|
||||||
|
- POST /apps/<name>/render-route
|
||||||
|
- POST /apps/<name>/deploy
|
||||||
|
- POST /apps/<name>/stop
|
||||||
|
- POST /apps/<name>/remove
|
||||||
|
|
||||||
|
## Example payloads
|
||||||
|
|
||||||
|
Create app:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "whoami",
|
||||||
|
"domain": "whoami.srazka.com",
|
||||||
|
"port": 18080,
|
||||||
|
"auth": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove and keep volumes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"keepVolumes": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:9911/health | jq .
|
||||||
|
curl -s http://127.0.0.1:9911/apps | jq .
|
||||||
|
```
|
||||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# panelctl quickstart
|
||||||
|
|
||||||
|
This repository now includes a small helper command named panelctl for Phase 2.
|
||||||
|
|
||||||
|
Base directory:
|
||||||
|
- /home/reudy/containers
|
||||||
|
|
||||||
|
Generated structure:
|
||||||
|
- /home/reudy/containers/stacks/<app>/compose.yaml
|
||||||
|
- /home/reudy/containers/volumes/<app>/data
|
||||||
|
- /home/reudy/containers/routes/<app>.caddy
|
||||||
|
- /home/reudy/containers/state/apps/<app>.env
|
||||||
|
|
||||||
|
Quick workflow:
|
||||||
|
1. Create a new app definition:
|
||||||
|
panelctl init whoami whoami.srazka.com 18080 true
|
||||||
|
2. Deploy it with Podman compose:
|
||||||
|
panelctl deploy whoami
|
||||||
|
3. If deploy says caddy reload needs root:
|
||||||
|
sudo systemctl reload caddy
|
||||||
|
4. List apps:
|
||||||
|
panelctl list
|
||||||
|
5. Inspect one app:
|
||||||
|
panelctl show whoami
|
||||||
|
6. Remove app but keep data:
|
||||||
|
panelctl remove whoami --keep-volumes
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The default compose file uses traefik/whoami for smoke testing.
|
||||||
|
- Edit each generated compose.yaml before production use.
|
||||||
|
- App names must be lowercase slugs.
|
||||||
|
|
||||||
|
API service:
|
||||||
|
- Nix now runs panel-api as a systemd service on 127.0.0.1:9911.
|
||||||
|
- Caddy proxies https://panel.srazka.com to panel-api with your existing forward_auth pattern.
|
||||||
|
- Open https://panel.srazka.com for the web UI.
|
||||||
|
- API docs are in panel/API.md.
|
||||||
510
panel-api.py
Normal file
510
panel-api.py
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
PANELCTL = os.environ.get("PANELCTL_PATH", "/run/current-system/sw/bin/panelctl")
|
||||||
|
BIND = os.environ.get("PANEL_API_BIND", "127.0.0.1")
|
||||||
|
PORT = int(os.environ.get("PANEL_API_PORT", "9911"))
|
||||||
|
|
||||||
|
INDEX_HTML = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Panel</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--ink: #1f1f1f;
|
||||||
|
--paper: #fbf6ef;
|
||||||
|
--accent: #0e8a6b;
|
||||||
|
--accent-dark: #0a664f;
|
||||||
|
--line: #1f1f1f22;
|
||||||
|
--warn: #7a1818;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Space Grotesk", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 400px at -10% -20%, #ffd79a 0%, transparent 60%),
|
||||||
|
radial-gradient(900px 300px at 120% 5%, #9de2cf 0%, transparent 55%),
|
||||||
|
var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 7px 7px 0 #0000001a;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.6rem, 3.5vw, 2.6rem);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 880px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1.1fr 1.9fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 10px 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 6px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #fff;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.ok { border-color: #1b7a39; }
|
||||||
|
.status.err { border-color: var(--warn); }
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: appear 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes appear {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<section class="hero fade-in">
|
||||||
|
<h1>Containers Panel</h1>
|
||||||
|
<p class="sub">Rootless Podman + Caddy routes from one place.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<article class="card fade-in">
|
||||||
|
<h2>Create App</h2>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" placeholder="whoami" />
|
||||||
|
<label for="domain">Domain</label>
|
||||||
|
<input id="domain" placeholder="whoami.srazka.com" />
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<label for="port">Host Port</label>
|
||||||
|
<input id="port" type="number" min="1024" max="65535" value="18080" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="auth">Protected</label>
|
||||||
|
<select id="auth">
|
||||||
|
<option value="true" selected>true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack" style="margin-top: 12px;">
|
||||||
|
<button class="primary" id="createBtn">Create</button>
|
||||||
|
<button id="refreshBtn">Refresh List</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card fade-in">
|
||||||
|
<h2>Apps</h2>
|
||||||
|
<table class="table" id="appsTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Domain</th>
|
||||||
|
<th>Upstream</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="appsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="status mono" id="status">Ready.</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const appsBody = document.getElementById("appsBody");
|
||||||
|
|
||||||
|
function setStatus(msg, isError = false) {
|
||||||
|
statusEl.textContent = msg;
|
||||||
|
statusEl.classList.toggle("ok", !isError);
|
||||||
|
statusEl.classList.toggle("err", isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, method = "GET", payload = null) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (payload) {
|
||||||
|
opts.headers["Content-Type"] = "application/json";
|
||||||
|
opts.body = JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || data.ok === false) {
|
||||||
|
throw new Error(data.stderr || data.error || data.stdout || "request failed");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionButton(label, fn) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.addEventListener("click", fn);
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(name, action, body = null) {
|
||||||
|
try {
|
||||||
|
setStatus(`Running ${action} on ${name}...`);
|
||||||
|
await api(`/apps/${name}/${action}`, "POST", body);
|
||||||
|
setStatus(`${action} completed for ${name}.`);
|
||||||
|
await loadApps();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`${action} failed for ${name}: ${err.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowForApp(app) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.className = "fade-in";
|
||||||
|
|
||||||
|
const actions = document.createElement("td");
|
||||||
|
actions.className = "stack";
|
||||||
|
actions.appendChild(actionButton("Deploy", () => runAction(app.name, "deploy")));
|
||||||
|
actions.appendChild(actionButton("Stop", () => runAction(app.name, "stop")));
|
||||||
|
actions.appendChild(actionButton("Route", () => runAction(app.name, "render-route")));
|
||||||
|
actions.appendChild(actionButton("Remove", async () => {
|
||||||
|
const keep = window.confirm("Keep volumes? Press OK to keep, Cancel to delete.");
|
||||||
|
await runAction(app.name, "remove", { keepVolumes: keep });
|
||||||
|
}));
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="mono">${app.name}</td>
|
||||||
|
<td class="mono">${app.domain}</td>
|
||||||
|
<td class="mono">${app.upstream}</td>
|
||||||
|
`;
|
||||||
|
tr.appendChild(actions);
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
try {
|
||||||
|
const data = await api("/apps");
|
||||||
|
appsBody.innerHTML = "";
|
||||||
|
if (!data.apps.length) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = '<td colspan="4" class="mono">No apps yet.</td>';
|
||||||
|
appsBody.appendChild(tr);
|
||||||
|
} else {
|
||||||
|
data.apps.forEach((app) => appsBody.appendChild(rowForApp(app)));
|
||||||
|
}
|
||||||
|
setStatus(`Loaded ${data.apps.length} app(s).`);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Failed to load apps: ${err.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp() {
|
||||||
|
const name = document.getElementById("name").value.trim();
|
||||||
|
const domain = document.getElementById("domain").value.trim();
|
||||||
|
const port = Number(document.getElementById("port").value);
|
||||||
|
const auth = document.getElementById("auth").value === "true";
|
||||||
|
|
||||||
|
if (!name || !domain || !port) {
|
||||||
|
setStatus("Name, domain and port are required.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus(`Creating ${name}...`);
|
||||||
|
await api("/apps/init", "POST", { name, domain, port, auth });
|
||||||
|
setStatus(`Created ${name}.`);
|
||||||
|
await loadApps();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Create failed: ${err.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("createBtn").addEventListener("click", createApp);
|
||||||
|
document.getElementById("refreshBtn").addEventListener("click", loadApps);
|
||||||
|
loadApps();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def run_panelctl(args):
|
||||||
|
proc = subprocess.run(
|
||||||
|
[PANELCTL, *args],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": proc.returncode == 0,
|
||||||
|
"code": proc.returncode,
|
||||||
|
"stdout": proc.stdout.strip(),
|
||||||
|
"stderr": proc.stderr.strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_env_blob(blob):
|
||||||
|
out = {}
|
||||||
|
for line in blob.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
out[key] = value.strip().strip('"')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def _html(self, code, body):
|
||||||
|
payload = body.encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(payload)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(payload)
|
||||||
|
|
||||||
|
def _json(self, code, payload):
|
||||||
|
body = json.dumps(payload, indent=2).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _read_json(self):
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length == 0:
|
||||||
|
return {}
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
|
||||||
|
if path == "/":
|
||||||
|
self._html(200, INDEX_HTML)
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/health":
|
||||||
|
self._json(200, {"ok": True, "service": "panel-api"})
|
||||||
|
return
|
||||||
|
|
||||||
|
if path == "/apps":
|
||||||
|
result = run_panelctl(["list"])
|
||||||
|
if not result["ok"]:
|
||||||
|
self._json(500, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
apps = []
|
||||||
|
for line in result["stdout"].splitlines():
|
||||||
|
if not line.strip() or line.strip() == "no apps found":
|
||||||
|
continue
|
||||||
|
# format: name domain upstream auth=true|false
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) < 4:
|
||||||
|
continue
|
||||||
|
app = {
|
||||||
|
"name": fields[0],
|
||||||
|
"domain": fields[1],
|
||||||
|
"upstream": fields[2],
|
||||||
|
"auth": fields[3].replace("auth=", ""),
|
||||||
|
}
|
||||||
|
apps.append(app)
|
||||||
|
|
||||||
|
self._json(200, {"ok": True, "apps": apps})
|
||||||
|
return
|
||||||
|
|
||||||
|
if path.startswith("/apps/"):
|
||||||
|
name = path.split("/")[-1]
|
||||||
|
if not name:
|
||||||
|
self._json(400, {"ok": False, "error": "missing app name"})
|
||||||
|
return
|
||||||
|
|
||||||
|
result = run_panelctl(["show", name])
|
||||||
|
if not result["ok"]:
|
||||||
|
self._json(404, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._json(200, {"ok": True, "app": parse_env_blob(result["stdout"])})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._json(404, {"ok": False, "error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
|
||||||
|
if path == "/apps/init":
|
||||||
|
try:
|
||||||
|
payload = self._read_json()
|
||||||
|
name = payload["name"]
|
||||||
|
domain = payload["domain"]
|
||||||
|
port = str(payload["port"])
|
||||||
|
auth = str(payload.get("auth", True)).lower()
|
||||||
|
except Exception as exc:
|
||||||
|
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||||
|
return
|
||||||
|
|
||||||
|
result = run_panelctl(["init", name, domain, port, auth])
|
||||||
|
self._json(200 if result["ok"] else 400, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
action_prefix = "/apps/"
|
||||||
|
if path.startswith(action_prefix):
|
||||||
|
parts = [p for p in path.split("/") if p]
|
||||||
|
# /apps/<name>/<action>
|
||||||
|
if len(parts) == 3:
|
||||||
|
_, name, action = parts
|
||||||
|
if action in {"deploy", "stop", "render-route"}:
|
||||||
|
result = run_panelctl([action, name])
|
||||||
|
self._json(200 if result["ok"] else 400, result)
|
||||||
|
return
|
||||||
|
if action == "remove":
|
||||||
|
payload = {}
|
||||||
|
try:
|
||||||
|
payload = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
keep = payload.get("keepVolumes", False)
|
||||||
|
args = ["remove", name]
|
||||||
|
if keep:
|
||||||
|
args.append("--keep-volumes")
|
||||||
|
result = run_panelctl(args)
|
||||||
|
self._json(200 if result["ok"] else 400, result)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._json(404, {"ok": False, "error": "not found"})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
server = HTTPServer((BIND, PORT), Handler)
|
||||||
|
print(f"panel-api listening on http://{BIND}:{PORT}")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
335
panelctl.sh
Normal file
335
panelctl.sh
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_DIR="${PANEL_BASE_DIR:-/home/reudy/containers}"
|
||||||
|
STACKS_DIR="${BASE_DIR}/stacks"
|
||||||
|
VOLUMES_DIR="${BASE_DIR}/volumes"
|
||||||
|
ROUTES_DIR="${BASE_DIR}/routes"
|
||||||
|
STATE_DIR="${BASE_DIR}/state"
|
||||||
|
APPS_DIR="${STATE_DIR}/apps"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
'
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
panelctl - minimal app panel helper
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
panelctl init <name> <domain> <port> [auth]
|
||||||
|
panelctl render-route <name>
|
||||||
|
panelctl deploy <name>
|
||||||
|
panelctl stop <name>
|
||||||
|
panelctl remove <name> [--keep-volumes]
|
||||||
|
panelctl list
|
||||||
|
panelctl show <name>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
panelctl init whoami whoami.srazka.com 18080 true
|
||||||
|
panelctl deploy whoami
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_base_dirs() {
|
||||||
|
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_name() {
|
||||||
|
local name="$1"
|
||||||
|
[[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_domain() {
|
||||||
|
local domain="$1"
|
||||||
|
[[ "${domain}" =~ ^[A-Za-z0-9.-]+$ ]] || fail "invalid domain '${domain}'"
|
||||||
|
[[ "${domain}" == *.* ]] || fail "domain must include a dot"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_port() {
|
||||||
|
local port="$1"
|
||||||
|
[[ "${port}" =~ ^[0-9]+$ ]] || fail "port must be numeric"
|
||||||
|
(( port >= 1024 && port <= 65535 )) || fail "port must be in range 1024-65535"
|
||||||
|
}
|
||||||
|
|
||||||
|
app_manifest() {
|
||||||
|
local name="$1"
|
||||||
|
echo "${APPS_DIR}/${name}.env"
|
||||||
|
}
|
||||||
|
|
||||||
|
app_stack_dir() {
|
||||||
|
local name="$1"
|
||||||
|
echo "${STACKS_DIR}/${name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
app_volume_dir() {
|
||||||
|
local name="$1"
|
||||||
|
echo "${VOLUMES_DIR}/${name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
app_route_file() {
|
||||||
|
local name="$1"
|
||||||
|
echo "${ROUTES_DIR}/${name}.caddy"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_app() {
|
||||||
|
local name="$1"
|
||||||
|
local manifest
|
||||||
|
manifest="$(app_manifest "${name}")"
|
||||||
|
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "${manifest}"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_command() {
|
||||||
|
if podman compose version >/dev/null 2>&1; then
|
||||||
|
echo "podman compose"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v podman-compose >/dev/null 2>&1; then
|
||||||
|
echo "podman-compose"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail "no compose command available (need 'podman compose' or 'podman-compose')"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_default_compose() {
|
||||||
|
local name="$1"
|
||||||
|
local port="$2"
|
||||||
|
local stack_dir
|
||||||
|
local volume_dir
|
||||||
|
stack_dir="$(app_stack_dir "${name}")"
|
||||||
|
volume_dir="$(app_volume_dir "${name}")"
|
||||||
|
|
||||||
|
cat >"${stack_dir}/compose.yaml" <<EOF
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: traefik/whoami:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${port}:80"
|
||||||
|
volumes:
|
||||||
|
- ${volume_dir}/data:/data
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
write_manifest() {
|
||||||
|
local name="$1"
|
||||||
|
local domain="$2"
|
||||||
|
local port="$3"
|
||||||
|
local auth="$4"
|
||||||
|
local manifest
|
||||||
|
local stack_dir
|
||||||
|
local volume_dir
|
||||||
|
local route_file
|
||||||
|
|
||||||
|
manifest="$(app_manifest "${name}")"
|
||||||
|
stack_dir="$(app_stack_dir "${name}")"
|
||||||
|
volume_dir="$(app_volume_dir "${name}")"
|
||||||
|
route_file="$(app_route_file "${name}")"
|
||||||
|
|
||||||
|
cat >"${manifest}" <<EOF
|
||||||
|
APP_NAME="${name}"
|
||||||
|
APP_DOMAIN="${domain}"
|
||||||
|
APP_PORT="${port}"
|
||||||
|
APP_UPSTREAM="127.0.0.1:${port}"
|
||||||
|
APP_AUTH_PROTECTED="${auth}"
|
||||||
|
APP_STACK_DIR="${stack_dir}"
|
||||||
|
APP_COMPOSE_FILE="${stack_dir}/compose.yaml"
|
||||||
|
APP_VOLUME_DIR="${volume_dir}"
|
||||||
|
APP_ROUTE_FILE="${route_file}"
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_init() {
|
||||||
|
local name="$1"
|
||||||
|
local domain="$2"
|
||||||
|
local port="$3"
|
||||||
|
local auth="${4:-true}"
|
||||||
|
|
||||||
|
validate_name "${name}"
|
||||||
|
validate_domain "${domain}"
|
||||||
|
validate_port "${port}"
|
||||||
|
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
|
||||||
|
|
||||||
|
ensure_base_dirs
|
||||||
|
|
||||||
|
local manifest
|
||||||
|
local stack_dir
|
||||||
|
local volume_dir
|
||||||
|
manifest="$(app_manifest "${name}")"
|
||||||
|
stack_dir="$(app_stack_dir "${name}")"
|
||||||
|
volume_dir="$(app_volume_dir "${name}")"
|
||||||
|
|
||||||
|
[[ ! -f "${manifest}" ]] || fail "app '${name}' already exists"
|
||||||
|
|
||||||
|
mkdir -p "${stack_dir}" "${volume_dir}/data"
|
||||||
|
write_default_compose "${name}" "${port}"
|
||||||
|
write_manifest "${name}" "${domain}" "${port}" "${auth}"
|
||||||
|
cmd_render_route "${name}"
|
||||||
|
|
||||||
|
echo "initialized app '${name}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_render_route() {
|
||||||
|
local name="$1"
|
||||||
|
validate_name "${name}"
|
||||||
|
load_app "${name}"
|
||||||
|
|
||||||
|
local auth_block=""
|
||||||
|
if [[ "${APP_AUTH_PROTECTED}" == "true" ]]; then
|
||||||
|
auth_block="${FORWARD_AUTH_BLOCK}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >"${APP_ROUTE_FILE}" <<EOF
|
||||||
|
${APP_DOMAIN} {
|
||||||
|
${auth_block} reverse_proxy ${APP_UPSTREAM}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "rendered route ${APP_ROUTE_FILE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
maybe_reload_caddy() {
|
||||||
|
if [[ -x /run/current-system/sw/bin/caddy && -f /etc/caddy/Caddyfile ]]; then
|
||||||
|
/run/current-system/sw/bin/caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${EUID}" -eq 0 ]]; then
|
||||||
|
systemctl reload caddy
|
||||||
|
echo "reloaded caddy"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||||
|
sudo -n systemctl reload caddy
|
||||||
|
echo "reloaded caddy via sudo"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "caddy reload requires root; run: sudo systemctl reload caddy"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_deploy() {
|
||||||
|
local name="$1"
|
||||||
|
validate_name "${name}"
|
||||||
|
load_app "${name}"
|
||||||
|
|
||||||
|
local compose
|
||||||
|
compose="$(compose_command)"
|
||||||
|
|
||||||
|
cmd_render_route "${name}"
|
||||||
|
${compose} -f "${APP_COMPOSE_FILE}" up -d
|
||||||
|
maybe_reload_caddy
|
||||||
|
|
||||||
|
echo "deployed app '${name}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stop() {
|
||||||
|
local name="$1"
|
||||||
|
validate_name "${name}"
|
||||||
|
load_app "${name}"
|
||||||
|
|
||||||
|
local compose
|
||||||
|
compose="$(compose_command)"
|
||||||
|
${compose} -f "${APP_COMPOSE_FILE}" down
|
||||||
|
echo "stopped app '${name}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_remove() {
|
||||||
|
local name="$1"
|
||||||
|
local keep_volumes="${2:-}"
|
||||||
|
validate_name "${name}"
|
||||||
|
load_app "${name}"
|
||||||
|
|
||||||
|
local compose
|
||||||
|
compose="$(compose_command)"
|
||||||
|
${compose} -f "${APP_COMPOSE_FILE}" down || true
|
||||||
|
|
||||||
|
rm -f "${APP_ROUTE_FILE}" "$(app_manifest "${name}")"
|
||||||
|
rm -rf "${APP_STACK_DIR}"
|
||||||
|
|
||||||
|
if [[ "${keep_volumes}" != "--keep-volumes" ]]; then
|
||||||
|
rm -rf "${APP_VOLUME_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
maybe_reload_caddy
|
||||||
|
echo "removed app '${name}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_list() {
|
||||||
|
ensure_base_dirs
|
||||||
|
local found=0
|
||||||
|
for mf in "${APPS_DIR}"/*.env; do
|
||||||
|
[[ -e "${mf}" ]] || continue
|
||||||
|
found=1
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "${mf}"
|
||||||
|
echo "${APP_NAME} ${APP_DOMAIN} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${found}" -eq 0 ]]; then
|
||||||
|
echo "no apps found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_show() {
|
||||||
|
local name="$1"
|
||||||
|
validate_name "${name}"
|
||||||
|
local mf
|
||||||
|
mf="$(app_manifest "${name}")"
|
||||||
|
[[ -f "${mf}" ]] || fail "app '${name}' does not exist"
|
||||||
|
cat "${mf}"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local cmd="${1:-}"
|
||||||
|
|
||||||
|
case "${cmd}" in
|
||||||
|
init)
|
||||||
|
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domain> <port> [auth]"
|
||||||
|
cmd_init "$2" "$3" "$4" "${5:-true}"
|
||||||
|
;;
|
||||||
|
render-route)
|
||||||
|
[[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>"
|
||||||
|
cmd_render_route "$2"
|
||||||
|
;;
|
||||||
|
deploy)
|
||||||
|
[[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>"
|
||||||
|
cmd_deploy "$2"
|
||||||
|
;;
|
||||||
|
stop)
|
||||||
|
[[ $# -eq 2 ]] || fail "usage: panelctl stop <name>"
|
||||||
|
cmd_stop "$2"
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
[[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]"
|
||||||
|
cmd_remove "$2" "${3:-}"
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
[[ $# -eq 1 ]] || fail "usage: panelctl list"
|
||||||
|
cmd_list
|
||||||
|
;;
|
||||||
|
show)
|
||||||
|
[[ $# -eq 2 ]] || fail "usage: panelctl show <name>"
|
||||||
|
cmd_show "$2"
|
||||||
|
;;
|
||||||
|
""|-h|--help|help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unknown command '${cmd}'"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue