added support for multiple routes multiple ports

This commit is contained in:
Jakub Dorfman 2026-05-20 00:21:25 +02:00
parent c93982936b
commit 02b2909601
4 changed files with 445 additions and 151 deletions

View file

@ -19,6 +19,7 @@ Default bind: `127.0.0.1:9911`
|--------|------|-------------| |--------|------|-------------|
| GET | `/apps` | List all apps | | GET | `/apps` | List all apps |
| GET | `/apps/<name>` | Show single app manifest | | GET | `/apps/<name>` | Show single app manifest |
| GET | `/apps/<name>/routes` | Get parsed route entries |
| GET | `/apps/<name>/status` | Container status (running/stopped) | | GET | `/apps/<name>/status` | Container status (running/stopped) |
| GET | `/apps/<name>/compose` | Read compose.yaml content | | GET | `/apps/<name>/compose` | Read compose.yaml content |
| GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) | | GET | `/apps/<name>/logs?tail=N` | Fetch last N log lines (default 100) |
@ -30,6 +31,7 @@ Default bind: `127.0.0.1:9911`
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| POST | `/apps/init` | Create a new app | | POST | `/apps/init` | Create a new app |
| POST | `/apps/<name>/routes` | Update routes (hot — Caddy reloads automatically) |
| POST | `/apps/<name>/deploy` | Deploy (compose up + caddy reload) | | POST | `/apps/<name>/deploy` | Deploy (compose up + caddy reload) |
| POST | `/apps/<name>/restart` | Restart (compose down + up) | | POST | `/apps/<name>/restart` | Restart (compose down + up) |
| POST | `/apps/<name>/stop` | Stop (compose down) | | POST | `/apps/<name>/stop` | Stop (compose down) |
@ -42,35 +44,27 @@ Default bind: `127.0.0.1:9911`
## Example payloads ## Example payloads
### Create app (single domain) ### Create app (single route)
```json ```json
{ {
"name": "whoami", "name": "whoami",
"domain": "whoami.srazka.com", "routes": [
"port": 18080, {"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"}
],
"auth": true "auth": true
} }
``` ```
### Create app (multiple domains) ### Create app (multiple routes, different ports)
```json ```json
{ {
"name": "myapp", "name": "myapp",
"domain": "app.srazka.com,www.app.srazka.com", "routes": [
"port": 18081, {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
"auth": true {"domain": "api.app.srazka.com", "upstream": "127.0.0.1:18081"}
} ],
```
Or using the `domains` array format:
```json
{
"name": "myapp",
"domains": ["app.srazka.com", "www.app.srazka.com"],
"port": 18081,
"auth": true "auth": true
} }
``` ```
@ -80,14 +74,28 @@ Or using the `domains` array format:
```json ```json
{ {
"name": "wildcard", "name": "wildcard",
"domain": "*.srazka.com", "routes": [
"port": 18082, {"domain": "*.srazka.com", "upstream": "127.0.0.1:18082"}
],
"auth": false "auth": false
} }
``` ```
Note: Wildcard domains require DNS challenge configuration in Caddy. Note: Wildcard domains require DNS challenge configuration in Caddy.
### Update routes (hot)
```json
{
"routes": [
{"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
{"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"}
]
}
```
Caddy reloads automatically via the systemd path watcher. Containers stay running.
### Save compose ### Save compose
```json ```json
@ -112,6 +120,19 @@ Note: Wildcard domains require DNS challenge configuration in Caddy.
} }
``` ```
## Routes response
```json
{
"ok": true,
"name": "myapp",
"routes": [
{"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
{"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"}
]
}
```
## Response format ## Response format
All JSON responses include an `ok` boolean: All JSON responses include an `ok` boolean:

View file

@ -149,6 +149,19 @@
.domain-input-row { display: flex; gap: 6px; margin-top: 6px; } .domain-input-row { display: flex; gap: 6px; margin-top: 6px; }
.domain-input-row input { flex: 1; } .domain-input-row input { flex: 1; }
/* ── Route table ── */
.route-row {
display: flex; gap: 6px; align-items: center;
margin-top: 6px;
}
.route-row input {
flex: 1;
padding: 6px 8px;
font-size: .85rem;
}
.route-row input:first-child { flex: 3; }
.route-row button { flex-shrink: 0; }
/* ── App list ── */ /* ── App list ── */
.app-list { display: flex; flex-direction: column; gap: 12px; } .app-list { display: flex; flex-direction: column; gap: 12px; }
@ -294,28 +307,21 @@
<label for="name">Name</label> <label for="name">Name</label>
<input id="name" placeholder="whoami" autocomplete="off" /> <input id="name" placeholder="whoami" autocomplete="off" />
<label>Domains</label> <label>Routes <span style="font-weight:400;color:var(--muted);font-size:.78rem;">domain | upstream</span></label>
<div class="domain-tags" id="domainTags"></div> <div id="routeTable"></div>
<div class="domain-input-row"> <div class="btn-group" style="margin-top:6px;">
<input id="domainInput" placeholder="whoami.srazka.com" /> <button class="btn-sm" id="addRouteBtn">+ Add Route</button>
<button class="btn-sm" id="addDomainBtn">Add</button>
</div> </div>
<p style="font-size:.75rem;color:var(--muted);margin-top:4px;"> <p style="font-size:.75rem;color:var(--muted);margin-top:4px;">
Supports wildcards: <code>*.example.com</code> Supports wildcards: <code>*.example.com</code>. Upstream: <code>127.0.0.1:PORT</code>
</p> </p>
<div class="row"> <div>
<div> <label for="auth">Protected</label>
<label for="port">Host Port</label> <select id="auth">
<input id="port" type="number" min="1024" max="65535" value="18080" /> <option value="true" selected>true</option>
</div> <option value="false">false</option>
<div> </select>
<label for="auth">Protected</label>
<select id="auth">
<option value="true" selected>true</option>
<option value="false">false</option>
</select>
</div>
</div> </div>
<label for="createSource">App Source</label> <label for="createSource">App Source</label>
@ -382,6 +388,8 @@ const api = {
}, },
getApps: () => api.request("/apps"), getApps: () => api.request("/apps"),
getApp: (n) => api.request(`/apps/${n}`), getApp: (n) => api.request(`/apps/${n}`),
getRoutes: (n) => api.request(`/apps/${n}/routes`),
setRoutes: (n, r) => api.request(`/apps/${n}/routes`, "POST", { routes: r }),
getStatus: (n) => api.request(`/apps/${n}/status`), getStatus: (n) => api.request(`/apps/${n}/status`),
getLogs: (n, tail=100)=> api.request(`/apps/${n}/logs?tail=${tail}`), getLogs: (n, tail=100)=> api.request(`/apps/${n}/logs?tail=${tail}`),
getCompose: (n) => api.request(`/apps/${n}/compose`), getCompose: (n) => api.request(`/apps/${n}/compose`),
@ -427,33 +435,33 @@ function setStatus(msg, isError = false) {
statusEl.className = "status-bar" + (isError ? " err" : msg !== "Ready." ? " ok" : ""); statusEl.className = "status-bar" + (isError ? " err" : msg !== "Ready." ? " ok" : "");
} }
// ─── Domain tag input ─── // ─── Route table input ───
const domainTags = []; let createRoutes = [{ domain: "", upstream: "" }];
const domainTagsEl = document.getElementById("domainTags");
const domainInput = document.getElementById("domainInput");
function renderDomainTags() { function renderRouteTable() {
domainTagsEl.innerHTML = ""; const table = document.getElementById("routeTable");
domainTags.forEach((d, i) => { table.innerHTML = "";
const tag = document.createElement("span"); createRoutes.forEach((r, i) => {
tag.className = "domain-tag"; const row = document.createElement("div");
tag.innerHTML = `${escHtml(d)} <button data-i="${i}">&times;</button>`; row.className = "route-row";
tag.querySelector("button").onclick = () => { domainTags.splice(i, 1); renderDomainTags(); }; row.innerHTML = `
domainTagsEl.appendChild(tag); <input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
<span style="color:var(--muted);font-weight:600;">→</span>
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
${createRoutes.length > 1 ? `<button class="btn-sm btn-danger" data-ridx="${i}">&times;</button>` : ""}
`;
row.querySelector(".route-domain").oninput = (e) => { createRoutes[i].domain = e.target.value; };
row.querySelector(".route-upstream").oninput = (e) => { createRoutes[i].upstream = e.target.value; };
const delBtn = row.querySelector("[data-ridx]");
if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); };
table.appendChild(row);
}); });
} }
document.getElementById("addDomainBtn").onclick = () => { document.getElementById("addRouteBtn").onclick = () => {
const v = domainInput.value.trim(); createRoutes.push({ domain: "", upstream: "" });
if (v && !domainTags.includes(v)) { renderRouteTable();
domainTags.push(v);
domainInput.value = "";
renderDomainTags();
}
}; };
domainInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); }
});
const sourceSelect = document.getElementById("createSource"); const sourceSelect = document.getElementById("createSource");
const sourceRaw = document.getElementById("sourceRaw"); const sourceRaw = document.getElementById("sourceRaw");
@ -466,10 +474,7 @@ sourceSelect.addEventListener("change", () => {
document.getElementById("createBtn").onclick = async () => { document.getElementById("createBtn").onclick = async () => {
const name = document.getElementById("name").value.trim(); const name = document.getElementById("name").value.trim();
const port = Number(document.getElementById("port").value);
const auth = document.getElementById("auth").value === "true"; const auth = document.getElementById("auth").value === "true";
const domains = [...domainTags];
const source_type = sourceSelect.value; const source_type = sourceSelect.value;
const compose_content = document.getElementById("createCompose").value; const compose_content = document.getElementById("createCompose").value;
const github_url = document.getElementById("createGithubUrl").value.trim(); const github_url = document.getElementById("createGithubUrl").value.trim();
@ -477,22 +482,32 @@ document.getElementById("createBtn").onclick = async () => {
const github_pat = document.getElementById("createGithubPat").value.trim(); const github_pat = document.getElementById("createGithubPat").value.trim();
if (!name) { setStatus("Name is required.", true); return; } if (!name) { setStatus("Name is required.", true); return; }
if (!domains.length) { setStatus("At least one domain is required.", true); return; }
if (!port || port < 1024 || port > 65535) { setStatus("Port must be 1024-65535.", true); return; } // Validate routes
const validRoutes = createRoutes.filter(r => r.domain.trim() && r.upstream.trim());
if (!validRoutes.length) { setStatus("At least one route with domain and upstream is required.", true); return; }
for (const r of validRoutes) {
const port = r.upstream.split(":").pop();
const pnum = Number(port);
if (!port || isNaN(pnum) || pnum < 1024 || pnum > 65535) {
setStatus(`Upstream port must be 1024-65535 in "${r.upstream}"`, true);
return;
}
}
if (source_type === "raw" && !compose_content.trim()) { setStatus("Compose YAML is required for raw source.", true); return; } if (source_type === "raw" && !compose_content.trim()) { setStatus("Compose YAML is required for raw source.", true); return; }
if (source_type === "github" && !github_url) { setStatus("Repository URL is required for GitHub source.", true); return; } if (source_type === "github" && !github_url) { setStatus("Repository URL is required for GitHub source.", true); return; }
try { try {
setStatus(`Creating ${name}...`); setStatus(`Creating ${name}...`);
await api.init({ name, domain: domains.join(","), port, auth, source_type, compose_content, github_url, github_branch, github_pat }); await api.init({ name, routes: validRoutes.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })), auth, source_type, compose_content, github_url, github_branch, github_pat });
setStatus(`Created ${name}.`); setStatus(`Created ${name}.`);
document.getElementById("name").value = ""; document.getElementById("name").value = "";
document.getElementById("createCompose").value = ""; document.getElementById("createCompose").value = "";
document.getElementById("createGithubUrl").value = ""; document.getElementById("createGithubUrl").value = "";
document.getElementById("createGithubPat").value = ""; document.getElementById("createGithubPat").value = "";
domainTags.length = 0; createRoutes = [{ domain: "", upstream: "" }];
renderDomainTags(); renderRouteTable();
await loadApps(); await loadApps();
} catch (err) { } catch (err) {
setStatus(`Create failed: ${err.message}`, true); setStatus(`Create failed: ${err.message}`, true);
@ -509,17 +524,23 @@ function escHtml(s) {
return d.innerHTML; return d.innerHTML;
} }
function formatDomains(domainsStr) {
return domainsStr.split(",").map(d => d.trim()).filter(Boolean);
}
// ─── App card rendering ─── // ─── App card rendering ───
function renderAppCard(app) { function renderAppCard(app) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "app-card fade-in"; card.className = "app-card fade-in";
card.dataset.name = app.name; card.dataset.name = app.name;
const domains = formatDomains(app.domains || app.domain); // Parse first_route for display
let firstDomain = app.first_route || app.domain || "";
let firstUpstream = app.upstream || "";
if (app.first_route && app.first_route.includes("|")) {
const parts = app.first_route.split("|");
firstDomain = parts[0];
firstUpstream = parts[1];
}
const routeCount = parseInt(app.route_count || "1", 10);
const extraLabel = routeCount > 1 ? ` +${routeCount - 1} more route${routeCount > 2 ? "s" : ""}` : "";
const isExpanded = expandedApp === app.name; const isExpanded = expandedApp === app.name;
card.innerHTML = ` card.innerHTML = `
@ -528,7 +549,7 @@ function renderAppCard(app) {
<span class="app-status-dot dot-unknown" id="dot-${app.name}"></span> <span class="app-status-dot dot-unknown" id="dot-${app.name}"></span>
<div class="app-info"> <div class="app-info">
<div class="app-name">${escHtml(app.name)}</div> <div class="app-name">${escHtml(app.name)}</div>
<div class="app-meta">${domains.map(d => escHtml(d)).join(", ")} → ${escHtml(app.upstream)} ${app.auth === "true" ? "🔒" : ""}</div> <div class="app-meta">${escHtml(firstDomain)} → ${escHtml(firstUpstream)}${escHtml(extraLabel)} ${app.auth === "true" ? "🔒" : ""}</div>
</div> </div>
</div> </div>
<div class="app-actions"> <div class="app-actions">
@ -591,9 +612,17 @@ function renderAppCard(app) {
</div> </div>
<div class="tab-panel" id="tab-routing-${app.name}"> <div class="tab-panel" id="tab-routing-${app.name}">
<div class="btn-group" style="margin-bottom:10px;"> <div class="btn-group" style="margin-bottom:10px;">
<button class="btn-sm btn-primary" data-action="fetch-routing" data-app="${app.name}">Load App Variables</button> <button class="btn-sm btn-primary" data-action="fetch-routing" data-app="${app.name}">Edit Routes</button>
</div>
<div id="routing-editor-${app.name}">
<div id="route-edit-table-${app.name}"></div>
<div class="btn-group" style="margin-top:8px;">
<button class="btn-sm" data-action="add-route-edit" data-app="${app.name}">+ Add Route</button>
<button class="btn-sm btn-primary" data-action="save-routes" data-app="${app.name}">Save Routes</button>
</div>
<p style="font-size:.75rem;color:var(--muted);margin-top:6px;">Saving updates the Caddy route file (hot reload). Edit the compose file separately if new ports need exposing.</p>
<div class="backup-list" style="margin-top:8px;" id="routing-info-${app.name}"></div>
</div> </div>
<div class="log-output" id="routing-${app.name}">Click "Load App Variables" to view routing details & directories.</div>
</div> </div>
</div> </div>
`; `;
@ -658,6 +687,7 @@ function attachCardListeners() {
if (tabName === "logs") loadLogs(app); if (tabName === "logs") loadLogs(app);
if (tabName === "backups") loadBackups(app); if (tabName === "backups") loadBackups(app);
if (tabName === "volumes") initVolumesTab(app); if (tabName === "volumes") initVolumesTab(app);
if (tabName === "routing") loadRouteEditor(app);
}; };
}); });
@ -781,23 +811,13 @@ async function handleAction(action, name, btnElement) {
uploadInput.click(); uploadInput.click();
break; break;
case "fetch-routing": case "fetch-routing":
setStatus(`Fetching routing details for ${name}...`); await loadRouteEditor(name);
const appRes = await api.getApp(name); break;
if (appRes.app) { case "save-routes":
const info = appRes.app; await saveRouteEditor(name);
document.getElementById(`routing-${name}`).innerHTML = ` break;
<b>Primary Domain:</b> ${escHtml(info.APP_DOMAIN || "-")} case "add-route-edit":
<b>All Domains:</b> ${escHtml(info.APP_DOMAINS || "-")} await addRouteEditRow(name);
<b>Upstream Target:</b> ${escHtml(info.APP_UPSTREAM || "-")}
<b>Protected:</b> ${escHtml(info.APP_AUTH_PROTECTED || "-")}
<b>Port:</b> ${escHtml(info.APP_PORT || "-")}
<b>Volumes Directory:</b> ${escHtml(info.APP_VOLUME_DIR || "-")}
<b>Stack Directory:</b> ${escHtml(info.APP_STACK_DIR || "-")}
<b>Caddy Route Block:</b> ${escHtml(info.APP_ROUTE_FILE || "-")}
`.trim();
}
setStatus(`Loaded routing for ${name}.`);
break; break;
} }
} catch (err) { } catch (err) {
@ -885,6 +905,73 @@ async function loadLogs(name) {
} }
} }
// ─── Route editor (routing tab) ───
let routeEditState = {}; // { appName: [{ domain, upstream }, ...] }
async function loadRouteEditor(name) {
try {
const data = await api.getRoutes(name);
routeEditState[name] = data.routes || [];
renderRouteEditor(name);
setStatus(`Loaded ${data.routes.length} route(s) for ${name}.`);
} catch (err) {
const info = document.getElementById(`routing-info-${name}`);
if (info) info.innerHTML = `<span style="color:var(--warn);">Failed: ${err.message}</span>`;
}
}
function renderRouteEditor(name) {
const table = document.getElementById(`route-edit-table-${name}`);
const routes = routeEditState[name] || [];
if (!table) return;
table.innerHTML = "";
routes.forEach((r, i) => {
const row = document.createElement("div");
row.className = "route-row";
row.innerHTML = `
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
<span style="color:var(--muted);font-weight:600;">→</span>
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
<button class="btn-sm btn-danger" data-ridx="${i}">&times;</button>
`;
row.querySelector(".route-domain").oninput = (e) => { routeEditState[name][i].domain = e.target.value; };
row.querySelector(".route-upstream").oninput = (e) => { routeEditState[name][i].upstream = e.target.value; };
row.querySelector("[data-ridx]").onclick = () => {
routeEditState[name].splice(i, 1);
renderRouteEditor(name);
};
table.appendChild(row);
});
if (!routes.length) {
table.innerHTML = '<div style="color:var(--muted);font-size:.85rem;padding:8px 0;">No routes defined.</div>';
}
}
async function addRouteEditRow(name) {
if (!routeEditState[name]) routeEditState[name] = [];
routeEditState[name].push({ domain: "", upstream: "" });
renderRouteEditor(name);
}
async function saveRouteEditor(name) {
const routes = routeEditState[name] || [];
const valid = routes.filter(r => r.domain.trim() && r.upstream.trim());
if (!valid.length) {
setStatus("At least one valid route is required.", true);
return;
}
try {
setStatus(`Saving routes for ${name}...`);
const cleanRoutes = valid.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() }));
await api.setRoutes(name, cleanRoutes);
routeEditState[name] = cleanRoutes;
renderRouteEditor(name);
setStatus(`Routes saved for ${name}. Caddy reloaded.`);
} catch (err) {
setStatus(`Save routes failed: ${err.message}`, true);
}
}
window.api = api; // Expose for inline html onclicks window.api = api; // Expose for inline html onclicks
async function initVolumesTab(name) { async function initVolumesTab(name) {
@ -1078,6 +1165,7 @@ async function loadBackups(name) {
} }
// ─── Init ─── // ─── Init ───
renderRouteTable();
loadApps(); loadApps();
// Auto-refresh every 15s // Auto-refresh every 15s

View file

@ -214,11 +214,23 @@ class Handler(BaseHTTPRequestHandler):
fields = line.split() fields = line.split()
if len(fields) < 4: if len(fields) < 4:
continue continue
# New format: name domain|upstream routes=N auth=bool [repo_url]
first_route = fields[1]
route_parts = first_route.split("|")
domain = route_parts[0].split(",")[0] if route_parts else first_route
upstream = route_parts[1] if len(route_parts) > 1 else ""
route_count_str = fields[2].replace("routes=", "")
# Backward compat: fields[2] may be upstream if old format
if not route_count_str.isdigit():
upstream = fields[2]
route_count_str = "1"
apps.append({ apps.append({
"name": fields[0], "name": fields[0],
"domain": fields[1].split(",")[0], "domain": domain,
"domains": fields[1], "domains": domain,
"upstream": fields[2], "upstream": upstream,
"first_route": first_route,
"route_count": route_count_str,
"auth": fields[3].replace("auth=", ""), "auth": fields[3].replace("auth=", ""),
"repo_url": fields[4] if len(fields) >= 5 else "", "repo_url": fields[4] if len(fields) >= 5 else "",
}) })
@ -312,6 +324,41 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(chunk) self.wfile.write(chunk)
return return
# /apps/<name>/routes — get parsed routes
if len(parts) == 3 and parts[0] == "apps" and parts[2] == "routes":
name = parts[1]
if not is_safe_name(name):
self._json(400, {"ok": False, "error": "invalid app name"})
return
result = run_panelctl(["show", name])
if not result["ok"]:
self._json(404, result)
return
env = parse_env_blob(result["stdout"])
routes_raw = env.get("APP_ROUTES", "")
# Backward compat: build from old APP_DOMAIN/APP_PORT/APP_UPSTREAM
if not routes_raw and "APP_DOMAIN" in env:
upstream = env.get("APP_UPSTREAM", f"127.0.0.1:{env.get('APP_PORT', '18080')}")
domains_str = env.get("APP_DOMAINS", env["APP_DOMAIN"])
routes_parts = []
for d in domains_str.split(","):
d = d.strip()
if d:
routes_parts.append(f"{d}|{upstream}")
routes_raw = ",".join(routes_parts)
routes = []
for entry in routes_raw.split(","):
entry = entry.strip()
if not entry:
continue
if "|" in entry:
domain, upstream = entry.split("|", 1)
routes.append({"domain": domain.strip(), "upstream": upstream.strip()})
else:
routes.append({"domain": entry.strip(), "upstream": ""})
self._json(200, {"ok": True, "name": name, "routes": routes})
return
# /apps/<name> — show single app # /apps/<name> — show single app
if len(parts) == 2 and parts[0] == "apps": if len(parts) == 2 and parts[0] == "apps":
name = parts[1] name = parts[1]
@ -533,14 +580,32 @@ class Handler(BaseHTTPRequestHandler):
try: try:
payload = self._read_json() payload = self._read_json()
name = payload["name"] name = payload["name"]
# Support both "domain" (string, possibly comma-separated) and "domains" (array)
if "domains" in payload and isinstance(payload["domains"], list):
domain = ",".join(payload["domains"])
else:
domain = str(payload.get("domain", ""))
port = str(payload["port"])
auth = str(payload.get("auth", True)).lower() auth = str(payload.get("auth", True)).lower()
source_type = payload.get("source_type", "default") source_type = payload.get("source_type", "default")
# Build routes string: "domain|upstream,domain|upstream,..."
routes_parts = []
if "routes" in payload and isinstance(payload["routes"], list):
for r in payload["routes"]:
d = r.get("domain", "").strip()
u = r.get("upstream", "").strip()
if d and u:
routes_parts.append(f"{d}|{u}")
elif "domain" in payload and "port" in payload:
# Backward compat: single domain + port
domain_str = payload.get("domain", "")
if "domains" in payload and isinstance(payload["domains"], list):
domain_str = ",".join(payload["domains"])
port = str(payload["port"])
for d in domain_str.split(","):
d = d.strip()
if d:
routes_parts.append(f"{d}|127.0.0.1:{port}")
else:
self._json(400, {"ok": False, "error": "missing 'routes' array or 'domain'+'port' fields"})
return
routes_str = ",".join(routes_parts)
except Exception as exc: except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"}) self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return return
@ -550,7 +615,7 @@ class Handler(BaseHTTPRequestHandler):
return return
try: try:
result = run_panelctl(["init", name, domain, port, auth]) result = run_panelctl(["init", name, routes_str, auth])
if not result["ok"]: if not result["ok"]:
self._json(400, result) self._json(400, result)
return return
@ -730,6 +795,33 @@ class Handler(BaseHTTPRequestHandler):
self._json(200 if result["ok"] else 400, result) self._json(200 if result["ok"] else 400, result)
return return
# POST /apps/<name>/routes — hot update routes
if action == "routes":
if not is_safe_name(name):
self._json(400, {"ok": False, "error": "invalid app name"})
return
try:
payload = self._read_json()
except Exception as exc:
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
return
route_list = payload.get("routes", [])
if not isinstance(route_list, list) or not route_list:
self._json(400, {"ok": False, "error": "routes must be a non-empty array"})
return
routes_parts = []
for r in route_list:
d = r.get("domain", "").strip()
u = r.get("upstream", "").strip()
if not d or not u:
self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"})
return
routes_parts.append(f"{d}|{u}")
routes_str = ",".join(routes_parts)
result = run_panelctl(["set-routes", name, routes_str])
self._json(200 if result["ok"] else 400, result)
return
# Simple panelctl pass-through actions # Simple panelctl pass-through actions
if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}: if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}:
if not is_safe_name(name): if not is_safe_name(name):

View file

@ -15,12 +15,38 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
} }
' '
validate_route_entry() {
local entry="$1"
# Format: domain|upstream (upstream = host:port)
local domain="${entry%%|*}"
local upstream="${entry#*|}"
[[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'"
[[ -n "${upstream}" ]] || fail "empty upstream in route entry '${entry}'"
[[ "${entry}" == *"|"* ]] || fail "route entry '${entry}' missing '|' separator (expected domain|upstream)"
validate_single_domain "${domain}"
# Validate upstream has a port
local upstream_port="${upstream##*:}"
[[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'"
validate_port "${upstream_port}"
}
validate_routes() {
local routes_str="$1"
IFS=',' read -ra entries <<< "${routes_str}"
[[ ${#entries[@]} -ge 1 ]] || fail "at least one route is required"
for entry in "${entries[@]}"; do
entry="$(echo "${entry}" | xargs)"
validate_route_entry "${entry}"
done
}
usage() { usage() {
cat <<'EOF' cat <<'EOF'
panelctl - minimal app panel helper panelctl - minimal app panel helper
Usage: Usage:
panelctl init <name> <domains> <port> [auth] panelctl init <name> "<domain>|<upstream>[,...]" [auth]
panelctl set-routes <name> "<domain>|<upstream>[,...]"
panelctl render-route <name> panelctl render-route <name>
panelctl deploy <name> panelctl deploy <name>
panelctl restart <name> panelctl restart <name>
@ -36,14 +62,15 @@ Usage:
panelctl list panelctl list
panelctl show <name> panelctl show <name>
Domains can be comma-separated for multiple domains: Each route is a domain|upstream pair. Upstream is host:port.
panelctl init myapp "app.example.com,www.example.com" 18080 true Multiple routes are comma-separated:
panelctl init myapp "app.example.com|127.0.0.1:18080,api.example.com|127.0.0.1:18081" true
Wildcard domains are supported (requires DNS challenge in Caddy): Wildcard domains are supported (requires DNS challenge in Caddy):
panelctl init myapp "*.example.com" 18080 true panelctl init myapp "*.example.com|127.0.0.1:18080" true
Examples: Examples:
panelctl init whoami whoami.srazka.com 18080 true panelctl init whoami "whoami.srazka.com|127.0.0.1:18080" true
panelctl deploy whoami panelctl deploy whoami
panelctl restart whoami panelctl restart whoami
panelctl status whoami panelctl status whoami
@ -128,6 +155,23 @@ load_app() {
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist" [[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "${manifest}" source "${manifest}"
# Backward compat: migrate old APP_DOMAIN/APP_PORT/APP_UPSTREAM to APP_ROUTES
if [[ -z "${APP_ROUTES:-}" && -n "${APP_DOMAIN:-}" ]]; then
local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}"
local routes=""
local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}"
IFS=',' read -ra domain_arr <<< "${domains_str}"
for d in "${domain_arr[@]}"; do
d="$(echo "${d}" | xargs)"
if [[ -n "${routes}" ]]; then
routes="${routes},${d}|${upstream}"
else
routes="${d}|${upstream}"
fi
done
APP_ROUTES="${routes}"
fi
} }
compose_command() { compose_command() {
@ -203,19 +247,24 @@ run_compose() {
write_default_compose() { write_default_compose() {
local name="$1" local name="$1"
local port="$2" local routes="$2"
local stack_dir local stack_dir
local volume_dir local volume_dir
stack_dir="$(app_stack_dir "${name}")" stack_dir="$(app_stack_dir "${name}")"
volume_dir="$(app_volume_dir "${name}")" volume_dir="$(app_volume_dir "${name}")"
# Use first route's upstream port for the default compose mapping
local first_route="${routes%%,*}"
local first_upstream="${first_route#*|}"
local container_port="${first_upstream##*:}"
cat >"${stack_dir}/compose.yaml" <<EOF cat >"${stack_dir}/compose.yaml" <<EOF
services: services:
app: app:
image: docker.io/traefik/whoami:latest image: docker.io/traefik/whoami:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:${port}:80" - "127.0.0.1:${container_port}:80"
volumes: volumes:
- ${volume_dir}/data:/data - ${volume_dir}/data:/data
EOF EOF
@ -223,9 +272,8 @@ EOF
write_manifest() { write_manifest() {
local name="$1" local name="$1"
local domains="$2" local routes="$2"
local port="$3" local auth="$3"
local auth="$4"
local manifest local manifest
local stack_dir local stack_dir
local volume_dir local volume_dir
@ -236,17 +284,9 @@ write_manifest() {
volume_dir="$(app_volume_dir "${name}")" volume_dir="$(app_volume_dir "${name}")"
route_file="$(app_route_file)" route_file="$(app_route_file)"
# First domain is the primary (used for APP_DOMAIN backward compat)
local primary_domain
IFS=',' read -ra domain_arr <<< "${domains}"
primary_domain="$(echo "${domain_arr[0]}" | xargs)"
cat >"${manifest}" <<EOF cat >"${manifest}" <<EOF
APP_NAME="${name}" APP_NAME="${name}"
APP_DOMAIN="${primary_domain}" APP_ROUTES="${routes}"
APP_DOMAINS="${domains}"
APP_PORT="${port}"
APP_UPSTREAM="127.0.0.1:${port}"
APP_AUTH_PROTECTED="${auth}" APP_AUTH_PROTECTED="${auth}"
APP_STACK_DIR="${stack_dir}" APP_STACK_DIR="${stack_dir}"
APP_COMPOSE_FILE="${stack_dir}/compose.yaml" APP_COMPOSE_FILE="${stack_dir}/compose.yaml"
@ -257,13 +297,11 @@ EOF
cmd_init() { cmd_init() {
local name="$1" local name="$1"
local domains="$2" local routes="$2"
local port="$3" local auth="${3:-true}"
local auth="${4:-true}"
validate_name "${name}" validate_name "${name}"
validate_domains "${domains}" validate_routes "${routes}"
validate_port "${port}"
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false" [[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
ensure_base_dirs ensure_base_dirs
@ -278,8 +316,8 @@ cmd_init() {
[[ ! -f "${manifest}" ]] || fail "app '${name}' already exists" [[ ! -f "${manifest}" ]] || fail "app '${name}' already exists"
mkdir -p "${stack_dir}" "${volume_dir}/data" mkdir -p "${stack_dir}" "${volume_dir}/data"
write_default_compose "${name}" "${port}" write_default_compose "${name}" "${routes}"
write_manifest "${name}" "${domains}" "${port}" "${auth}" write_manifest "${name}" "${routes}" "${auth}"
cmd_render_route "${name}" cmd_render_route "${name}"
log info "initialized app '${name}'" log info "initialized app '${name}'"
@ -295,19 +333,6 @@ cmd_render_route() {
auth_block="${FORWARD_AUTH_BLOCK}" auth_block="${FORWARD_AUTH_BLOCK}"
fi fi
# Build domain list for Caddy block header.
local caddy_domains=""
local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}"
IFS=',' read -ra domain_arr <<< "${domains_str}"
for d in "${domain_arr[@]}"; do
d="$(echo "${d}" | xargs)"
if [[ -n "${caddy_domains}" ]]; then
caddy_domains="${caddy_domains}, ${d}"
else
caddy_domains="${d}"
fi
done
local route_file local route_file
route_file="$(app_route_file)" route_file="$(app_route_file)"
@ -323,12 +348,17 @@ cmd_render_route() {
{ {
printf "# route:%s:start\n" "${name}" printf "# route:%s:start\n" "${name}"
printf "%s {\n" "${caddy_domains}" IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
if [[ -n "${auth_block}" ]]; then for entry in "${route_entries[@]}"; do
printf "%s\n" "${auth_block}" entry="$(echo "${entry}" | xargs)"
fi local domain="${entry%%|*}"
printf " reverse_proxy %s\n" "${APP_UPSTREAM}" local upstream="${entry#*|}"
printf "}\n" if [[ -n "${auth_block}" ]]; then
printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}"
else
printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}"
fi
done
printf "# route:%s:end\n" "${name}" printf "# route:%s:end\n" "${name}"
} >>"${tmp}" } >>"${tmp}"
@ -452,6 +482,45 @@ cmd_remove() {
log info "removed app '${name}'" log info "removed app '${name}'"
} }
cmd_set_routes() {
local name="$1"
local routes="$2"
local manifest
validate_name "${name}"
validate_routes "${routes}"
manifest="$(app_manifest "${name}")"
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
# Update APP_ROUTES in the manifest file, strip old fields, preserve others
local tmp
tmp="$(mktemp)"
local found_routes=false
while IFS= read -r line; do
case "${line}" in
APP_ROUTES=*)
printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}"
found_routes=true
;;
APP_DOMAIN=*|APP_DOMAINS=*|APP_PORT=*|APP_UPSTREAM=*)
# Strip old format fields
;;
*)
printf '%s\n' "${line}" >> "${tmp}"
;;
esac
done < "${manifest}"
if ! "${found_routes}"; then
printf 'APP_ROUTES="%s"\n' "${routes}" >> "${tmp}"
fi
install -m 0664 "${tmp}" "${manifest}"
# Re-render Caddy routes (auto-reloads via systemd.path watcher)
cmd_render_route "${name}"
log info "updated routes for app '${name}'. Edit compose file if new ports need exposing."
}
cmd_backup() { cmd_backup() {
local name="$1" local name="$1"
validate_name "${name}" validate_name "${name}"
@ -602,9 +671,29 @@ cmd_list() {
source /dev/null # reset any leftover variables source /dev/null # reset any leftover variables
unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true
source "${mf}" source "${mf}"
local domains="${APP_DOMAINS:-${APP_DOMAIN}}" # Backward compat: build APP_ROUTES from old format
local routes="${APP_ROUTES:-}"
if [[ -z "${routes}" && -n "${APP_DOMAIN:-}" ]]; then
local upstream="${APP_UPSTREAM:-127.0.0.1:${APP_PORT:-18080}}"
local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}"
IFS=',' read -ra domain_arr <<< "${domains_str}"
for d in "${domain_arr[@]}"; do
d="$(echo "${d}" | xargs)"
if [[ -n "${routes}" ]]; then
routes="${routes},${d}|${upstream}"
else
routes="${d}|${upstream}"
fi
done
fi
# Show abbreviated: first route's domain + upstream, and count
local first_route="${routes%%,*}"
local route_count=1
if [[ "${routes}" == *","* ]]; then
route_count="$(( $(grep -o ',' <<< "${routes}" | wc -l) + 1 ))"
fi
local repo_info="${APP_REPO_URL:-}" local repo_info="${APP_REPO_URL:-}"
echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED} ${repo_info}" echo "${APP_NAME} ${first_route} routes=${route_count} auth=${APP_AUTH_PROTECTED} ${repo_info}"
done done
if [[ "${found}" -eq 0 ]]; then if [[ "${found}" -eq 0 ]]; then
@ -626,8 +715,12 @@ main() {
case "${cmd}" in case "${cmd}" in
init) init)
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domains> <port> [auth]" [[ $# -ge 3 ]] || fail "usage: panelctl init <name> <routes> [auth]"
cmd_init "$2" "$3" "$4" "${5:-true}" cmd_init "$2" "$3" "${4:-true}"
;;
set-routes)
[[ $# -eq 3 ]] || fail "usage: panelctl set-routes <name> <routes>"
cmd_set_routes "$2" "$3"
;; ;;
render-route) render-route)
[[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>" [[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>"