added support for multiple routes multiple ports
This commit is contained in:
parent
c93982936b
commit
02b2909601
4 changed files with 445 additions and 151 deletions
59
panel/API.md
59
panel/API.md
|
|
@ -19,6 +19,7 @@ Default bind: `127.0.0.1:9911`
|
|||
|--------|------|-------------|
|
||||
| GET | `/apps` | List all apps |
|
||||
| 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>/compose` | Read compose.yaml content |
|
||||
| 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 |
|
||||
|--------|------|-------------|
|
||||
| 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>/restart` | Restart (compose down + up) |
|
||||
| POST | `/apps/<name>/stop` | Stop (compose down) |
|
||||
|
|
@ -42,35 +44,27 @@ Default bind: `127.0.0.1:9911`
|
|||
|
||||
## Example payloads
|
||||
|
||||
### Create app (single domain)
|
||||
### Create app (single route)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "whoami",
|
||||
"domain": "whoami.srazka.com",
|
||||
"port": 18080,
|
||||
"routes": [
|
||||
{"domain": "whoami.srazka.com", "upstream": "127.0.0.1:18080"}
|
||||
],
|
||||
"auth": true
|
||||
}
|
||||
```
|
||||
|
||||
### Create app (multiple domains)
|
||||
### Create app (multiple routes, different ports)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"domain": "app.srazka.com,www.app.srazka.com",
|
||||
"port": 18081,
|
||||
"auth": true
|
||||
}
|
||||
```
|
||||
|
||||
Or using the `domains` array format:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "myapp",
|
||||
"domains": ["app.srazka.com", "www.app.srazka.com"],
|
||||
"port": 18081,
|
||||
"routes": [
|
||||
{"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
|
||||
{"domain": "api.app.srazka.com", "upstream": "127.0.0.1:18081"}
|
||||
],
|
||||
"auth": true
|
||||
}
|
||||
```
|
||||
|
|
@ -80,14 +74,28 @@ Or using the `domains` array format:
|
|||
```json
|
||||
{
|
||||
"name": "wildcard",
|
||||
"domain": "*.srazka.com",
|
||||
"port": 18082,
|
||||
"routes": [
|
||||
{"domain": "*.srazka.com", "upstream": "127.0.0.1:18082"}
|
||||
],
|
||||
"auth": false
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```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
|
||||
|
||||
All JSON responses include an `ok` boolean:
|
||||
|
|
|
|||
|
|
@ -149,6 +149,19 @@
|
|||
.domain-input-row { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.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 { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
|
|
@ -294,28 +307,21 @@
|
|||
<label for="name">Name</label>
|
||||
<input id="name" placeholder="whoami" autocomplete="off" />
|
||||
|
||||
<label>Domains</label>
|
||||
<div class="domain-tags" id="domainTags"></div>
|
||||
<div class="domain-input-row">
|
||||
<input id="domainInput" placeholder="whoami.srazka.com" />
|
||||
<button class="btn-sm" id="addDomainBtn">Add</button>
|
||||
<label>Routes <span style="font-weight:400;color:var(--muted);font-size:.78rem;">domain | upstream</span></label>
|
||||
<div id="routeTable"></div>
|
||||
<div class="btn-group" style="margin-top:6px;">
|
||||
<button class="btn-sm" id="addRouteBtn">+ Add Route</button>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<label for="auth">Protected</label>
|
||||
<select id="auth">
|
||||
<option value="true" selected>true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label for="createSource">App Source</label>
|
||||
|
|
@ -382,6 +388,8 @@ const api = {
|
|||
},
|
||||
getApps: () => api.request("/apps"),
|
||||
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`),
|
||||
getLogs: (n, tail=100)=> api.request(`/apps/${n}/logs?tail=${tail}`),
|
||||
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" : "");
|
||||
}
|
||||
|
||||
// ─── Domain tag input ───
|
||||
const domainTags = [];
|
||||
const domainTagsEl = document.getElementById("domainTags");
|
||||
const domainInput = document.getElementById("domainInput");
|
||||
// ─── Route table input ───
|
||||
let createRoutes = [{ domain: "", upstream: "" }];
|
||||
|
||||
function renderDomainTags() {
|
||||
domainTagsEl.innerHTML = "";
|
||||
domainTags.forEach((d, i) => {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = "domain-tag";
|
||||
tag.innerHTML = `${escHtml(d)} <button data-i="${i}">×</button>`;
|
||||
tag.querySelector("button").onclick = () => { domainTags.splice(i, 1); renderDomainTags(); };
|
||||
domainTagsEl.appendChild(tag);
|
||||
function renderRouteTable() {
|
||||
const table = document.getElementById("routeTable");
|
||||
table.innerHTML = "";
|
||||
createRoutes.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)}" />
|
||||
${createRoutes.length > 1 ? `<button class="btn-sm btn-danger" data-ridx="${i}">×</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 = () => {
|
||||
const v = domainInput.value.trim();
|
||||
if (v && !domainTags.includes(v)) {
|
||||
domainTags.push(v);
|
||||
domainInput.value = "";
|
||||
renderDomainTags();
|
||||
}
|
||||
document.getElementById("addRouteBtn").onclick = () => {
|
||||
createRoutes.push({ domain: "", upstream: "" });
|
||||
renderRouteTable();
|
||||
};
|
||||
domainInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); document.getElementById("addDomainBtn").click(); }
|
||||
});
|
||||
|
||||
const sourceSelect = document.getElementById("createSource");
|
||||
const sourceRaw = document.getElementById("sourceRaw");
|
||||
|
|
@ -466,10 +474,7 @@ sourceSelect.addEventListener("change", () => {
|
|||
|
||||
document.getElementById("createBtn").onclick = async () => {
|
||||
const name = document.getElementById("name").value.trim();
|
||||
const port = Number(document.getElementById("port").value);
|
||||
const auth = document.getElementById("auth").value === "true";
|
||||
const domains = [...domainTags];
|
||||
|
||||
const source_type = sourceSelect.value;
|
||||
const compose_content = document.getElementById("createCompose").value;
|
||||
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();
|
||||
|
||||
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 === "github" && !github_url) { setStatus("Repository URL is required for GitHub source.", true); return; }
|
||||
|
||||
try {
|
||||
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}.`);
|
||||
document.getElementById("name").value = "";
|
||||
document.getElementById("createCompose").value = "";
|
||||
document.getElementById("createGithubUrl").value = "";
|
||||
document.getElementById("createGithubPat").value = "";
|
||||
domainTags.length = 0;
|
||||
renderDomainTags();
|
||||
createRoutes = [{ domain: "", upstream: "" }];
|
||||
renderRouteTable();
|
||||
await loadApps();
|
||||
} catch (err) {
|
||||
setStatus(`Create failed: ${err.message}`, true);
|
||||
|
|
@ -509,17 +524,23 @@ function escHtml(s) {
|
|||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function formatDomains(domainsStr) {
|
||||
return domainsStr.split(",").map(d => d.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── App card rendering ───
|
||||
function renderAppCard(app) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "app-card fade-in";
|
||||
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;
|
||||
|
||||
card.innerHTML = `
|
||||
|
|
@ -528,7 +549,7 @@ function renderAppCard(app) {
|
|||
<span class="app-status-dot dot-unknown" id="dot-${app.name}"></span>
|
||||
<div class="app-info">
|
||||
<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 class="app-actions">
|
||||
|
|
@ -591,9 +612,17 @@ function renderAppCard(app) {
|
|||
</div>
|
||||
<div class="tab-panel" id="tab-routing-${app.name}">
|
||||
<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 class="log-output" id="routing-${app.name}">Click "Load App Variables" to view routing details & directories.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -658,6 +687,7 @@ function attachCardListeners() {
|
|||
if (tabName === "logs") loadLogs(app);
|
||||
if (tabName === "backups") loadBackups(app);
|
||||
if (tabName === "volumes") initVolumesTab(app);
|
||||
if (tabName === "routing") loadRouteEditor(app);
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -781,23 +811,13 @@ async function handleAction(action, name, btnElement) {
|
|||
uploadInput.click();
|
||||
break;
|
||||
case "fetch-routing":
|
||||
setStatus(`Fetching routing details for ${name}...`);
|
||||
const appRes = await api.getApp(name);
|
||||
if (appRes.app) {
|
||||
const info = appRes.app;
|
||||
document.getElementById(`routing-${name}`).innerHTML = `
|
||||
<b>Primary Domain:</b> ${escHtml(info.APP_DOMAIN || "-")}
|
||||
<b>All Domains:</b> ${escHtml(info.APP_DOMAINS || "-")}
|
||||
<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}.`);
|
||||
await loadRouteEditor(name);
|
||||
break;
|
||||
case "save-routes":
|
||||
await saveRouteEditor(name);
|
||||
break;
|
||||
case "add-route-edit":
|
||||
await addRouteEditRow(name);
|
||||
break;
|
||||
}
|
||||
} 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}">×</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
|
||||
|
||||
async function initVolumesTab(name) {
|
||||
|
|
@ -1078,6 +1165,7 @@ async function loadBackups(name) {
|
|||
}
|
||||
|
||||
// ─── Init ───
|
||||
renderRouteTable();
|
||||
loadApps();
|
||||
|
||||
// Auto-refresh every 15s
|
||||
|
|
|
|||
|
|
@ -214,11 +214,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||
fields = line.split()
|
||||
if len(fields) < 4:
|
||||
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({
|
||||
"name": fields[0],
|
||||
"domain": fields[1].split(",")[0],
|
||||
"domains": fields[1],
|
||||
"upstream": fields[2],
|
||||
"domain": domain,
|
||||
"domains": domain,
|
||||
"upstream": upstream,
|
||||
"first_route": first_route,
|
||||
"route_count": route_count_str,
|
||||
"auth": fields[3].replace("auth=", ""),
|
||||
"repo_url": fields[4] if len(fields) >= 5 else "",
|
||||
})
|
||||
|
|
@ -312,6 +324,41 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self.wfile.write(chunk)
|
||||
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
|
||||
if len(parts) == 2 and parts[0] == "apps":
|
||||
name = parts[1]
|
||||
|
|
@ -533,14 +580,32 @@ class Handler(BaseHTTPRequestHandler):
|
|||
try:
|
||||
payload = self._read_json()
|
||||
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()
|
||||
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:
|
||||
self._json(400, {"ok": False, "error": f"invalid payload: {exc}"})
|
||||
return
|
||||
|
|
@ -550,7 +615,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
return
|
||||
|
||||
try:
|
||||
result = run_panelctl(["init", name, domain, port, auth])
|
||||
result = run_panelctl(["init", name, routes_str, auth])
|
||||
if not result["ok"]:
|
||||
self._json(400, result)
|
||||
return
|
||||
|
|
@ -730,6 +795,33 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self._json(200 if result["ok"] else 400, result)
|
||||
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
|
||||
if action in {"deploy", "stop", "restart", "render-route", "volume-clear"}:
|
||||
if not is_safe_name(name):
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
cat <<'EOF'
|
||||
panelctl - minimal app panel helper
|
||||
|
||||
Usage:
|
||||
panelctl init <name> <domains> <port> [auth]
|
||||
panelctl init <name> "<domain>|<upstream>[,...]" [auth]
|
||||
panelctl set-routes <name> "<domain>|<upstream>[,...]"
|
||||
panelctl render-route <name>
|
||||
panelctl deploy <name>
|
||||
panelctl restart <name>
|
||||
|
|
@ -36,14 +62,15 @@ Usage:
|
|||
panelctl list
|
||||
panelctl show <name>
|
||||
|
||||
Domains can be comma-separated for multiple domains:
|
||||
panelctl init myapp "app.example.com,www.example.com" 18080 true
|
||||
Each route is a domain|upstream pair. Upstream is host:port.
|
||||
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):
|
||||
panelctl init myapp "*.example.com" 18080 true
|
||||
panelctl init myapp "*.example.com|127.0.0.1:18080" true
|
||||
|
||||
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 restart whoami
|
||||
panelctl status whoami
|
||||
|
|
@ -128,6 +155,23 @@ load_app() {
|
|||
[[ -f "${manifest}" ]] || fail "app '${name}' does not exist"
|
||||
# shellcheck disable=SC1090
|
||||
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() {
|
||||
|
|
@ -203,19 +247,24 @@ run_compose() {
|
|||
|
||||
write_default_compose() {
|
||||
local name="$1"
|
||||
local port="$2"
|
||||
local routes="$2"
|
||||
local stack_dir
|
||||
local volume_dir
|
||||
stack_dir="$(app_stack_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
|
||||
services:
|
||||
app:
|
||||
image: docker.io/traefik/whoami:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${port}:80"
|
||||
- "127.0.0.1:${container_port}:80"
|
||||
volumes:
|
||||
- ${volume_dir}/data:/data
|
||||
EOF
|
||||
|
|
@ -223,9 +272,8 @@ EOF
|
|||
|
||||
write_manifest() {
|
||||
local name="$1"
|
||||
local domains="$2"
|
||||
local port="$3"
|
||||
local auth="$4"
|
||||
local routes="$2"
|
||||
local auth="$3"
|
||||
local manifest
|
||||
local stack_dir
|
||||
local volume_dir
|
||||
|
|
@ -236,17 +284,9 @@ write_manifest() {
|
|||
volume_dir="$(app_volume_dir "${name}")"
|
||||
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
|
||||
APP_NAME="${name}"
|
||||
APP_DOMAIN="${primary_domain}"
|
||||
APP_DOMAINS="${domains}"
|
||||
APP_PORT="${port}"
|
||||
APP_UPSTREAM="127.0.0.1:${port}"
|
||||
APP_ROUTES="${routes}"
|
||||
APP_AUTH_PROTECTED="${auth}"
|
||||
APP_STACK_DIR="${stack_dir}"
|
||||
APP_COMPOSE_FILE="${stack_dir}/compose.yaml"
|
||||
|
|
@ -257,13 +297,11 @@ EOF
|
|||
|
||||
cmd_init() {
|
||||
local name="$1"
|
||||
local domains="$2"
|
||||
local port="$3"
|
||||
local auth="${4:-true}"
|
||||
local routes="$2"
|
||||
local auth="${3:-true}"
|
||||
|
||||
validate_name "${name}"
|
||||
validate_domains "${domains}"
|
||||
validate_port "${port}"
|
||||
validate_routes "${routes}"
|
||||
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
|
||||
|
||||
ensure_base_dirs
|
||||
|
|
@ -278,8 +316,8 @@ cmd_init() {
|
|||
[[ ! -f "${manifest}" ]] || fail "app '${name}' already exists"
|
||||
|
||||
mkdir -p "${stack_dir}" "${volume_dir}/data"
|
||||
write_default_compose "${name}" "${port}"
|
||||
write_manifest "${name}" "${domains}" "${port}" "${auth}"
|
||||
write_default_compose "${name}" "${routes}"
|
||||
write_manifest "${name}" "${routes}" "${auth}"
|
||||
cmd_render_route "${name}"
|
||||
|
||||
log info "initialized app '${name}'"
|
||||
|
|
@ -295,19 +333,6 @@ cmd_render_route() {
|
|||
auth_block="${FORWARD_AUTH_BLOCK}"
|
||||
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
|
||||
route_file="$(app_route_file)"
|
||||
|
||||
|
|
@ -323,12 +348,17 @@ cmd_render_route() {
|
|||
|
||||
{
|
||||
printf "# route:%s:start\n" "${name}"
|
||||
printf "%s {\n" "${caddy_domains}"
|
||||
if [[ -n "${auth_block}" ]]; then
|
||||
printf "%s\n" "${auth_block}"
|
||||
fi
|
||||
printf " reverse_proxy %s\n" "${APP_UPSTREAM}"
|
||||
printf "}\n"
|
||||
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
|
||||
for entry in "${route_entries[@]}"; do
|
||||
entry="$(echo "${entry}" | xargs)"
|
||||
local domain="${entry%%|*}"
|
||||
local upstream="${entry#*|}"
|
||||
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}"
|
||||
} >>"${tmp}"
|
||||
|
||||
|
|
@ -452,6 +482,45 @@ cmd_remove() {
|
|||
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() {
|
||||
local name="$1"
|
||||
validate_name "${name}"
|
||||
|
|
@ -602,9 +671,29 @@ cmd_list() {
|
|||
source /dev/null # reset any leftover variables
|
||||
unset APP_REPO_URL APP_REPO_BRANCH APP_REPO_DIR 2>/dev/null || true
|
||||
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:-}"
|
||||
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
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
|
|
@ -626,8 +715,12 @@ main() {
|
|||
|
||||
case "${cmd}" in
|
||||
init)
|
||||
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domains> <port> [auth]"
|
||||
cmd_init "$2" "$3" "$4" "${5:-true}"
|
||||
[[ $# -ge 3 ]] || fail "usage: panelctl init <name> <routes> [auth]"
|
||||
cmd_init "$2" "$3" "${4:-true}"
|
||||
;;
|
||||
set-routes)
|
||||
[[ $# -eq 3 ]] || fail "usage: panelctl set-routes <name> <routes>"
|
||||
cmd_set_routes "$2" "$3"
|
||||
;;
|
||||
render-route)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl render-route <name>"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue