diff --git a/API.md b/API.md index 792450d..256cef6 100644 --- a/API.md +++ b/API.md @@ -69,6 +69,20 @@ Default bind: `127.0.0.1:9911` } ``` +### Create app (multiple routes, different ports, with paths) + +```json +{ + "name": "pocketbase", + "routes": [ + {"domain": "pb.srazka.com", "upstream": "127.0.0.1:8090", "path": "/_/*"} + ], + "auth": true +} +``` + +The `path` field is optional. When present, it generates a Caddy `reverse_proxy /_/* 127.0.0.1:8090` rule, letting you route requests to a specific path prefix within a domain. + ### Create app (wildcard domain) ```json @@ -89,11 +103,14 @@ Note: Wildcard domains require DNS challenge configuration in Caddy. { "routes": [ {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"}, - {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"} + {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"}, + {"domain": "pb.srazka.com", "upstream": "127.0.0.1:8090", "path": "/_/*"} ] } ``` +The optional `path` field generates a Caddy `reverse_proxy ` rule for sub-path routing. + Caddy reloads automatically via the systemd path watcher. Containers stay running. ### Save compose @@ -128,11 +145,13 @@ Caddy reloads automatically via the systemd path watcher. Containers stay runnin "name": "myapp", "routes": [ {"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"}, - {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081"} + {"domain": "api.srazka.com", "upstream": "127.0.0.1:18081", "path": "/api/*"} ] } ``` +The `path` field is only present when a route has a path configured. + ## Response format All JSON responses include an `ok` boolean: diff --git a/frontend/index.html b/frontend/index.html index 93b2e8d..154e071 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -155,12 +155,14 @@ margin-top: 6px; } .route-row input { - flex: 1; padding: 6px 8px; font-size: .85rem; } - .route-row input:first-child { flex: 3; } + .route-row .route-domain { flex: 3; } + .route-row .route-upstream { flex: 2; } + .route-row .route-path { flex: 1; } .route-row button { flex-shrink: 0; } + .route-row .arrow { color: var(--muted); font-weight: 600; flex-shrink: 0; } /* ── App list ── */ .app-list { display: flex; flex-direction: column; gap: 12px; } @@ -307,13 +309,13 @@ - +

- Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT + Supports wildcards: *.example.com. Upstream: 127.0.0.1:PORT. Optional path: /_/*

@@ -436,7 +438,7 @@ function setStatus(msg, isError = false) { } // ─── Route table input ─── -let createRoutes = [{ domain: "", upstream: "" }]; +let createRoutes = [{ domain: "", upstream: "", path: "" }]; function renderRouteTable() { const table = document.getElementById("routeTable"); @@ -446,12 +448,14 @@ function renderRouteTable() { row.className = "route-row"; row.innerHTML = ` - → + → + ${createRoutes.length > 1 ? `` : ""} `; row.querySelector(".route-domain").oninput = (e) => { createRoutes[i].domain = e.target.value; }; row.querySelector(".route-upstream").oninput = (e) => { createRoutes[i].upstream = e.target.value; }; + row.querySelector(".route-path").oninput = (e) => { createRoutes[i].path = e.target.value; }; const delBtn = row.querySelector("[data-ridx]"); if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); }; table.appendChild(row); @@ -459,7 +463,7 @@ function renderRouteTable() { } document.getElementById("addRouteBtn").onclick = () => { - createRoutes.push({ domain: "", upstream: "" }); + createRoutes.push({ domain: "", upstream: "", path: "" }); renderRouteTable(); }; @@ -500,13 +504,17 @@ document.getElementById("createBtn").onclick = async () => { try { setStatus(`Creating ${name}...`); - 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 }); + await api.init({ name, routes: validRoutes.map(r => { + const route = { domain: r.domain.trim(), upstream: r.upstream.trim() }; + if (r.path && r.path.trim()) route.path = r.path.trim(); + return route; + }), 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 = ""; - createRoutes = [{ domain: "", upstream: "" }]; + createRoutes = [{ domain: "", upstream: "", path: "" }]; renderRouteTable(); await loadApps(); } catch (err) { @@ -930,12 +938,14 @@ function renderRouteEditor(name) { row.className = "route-row"; row.innerHTML = ` - → + → + `; 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(".route-path").oninput = (e) => { routeEditState[name][i].path = e.target.value; }; row.querySelector("[data-ridx]").onclick = () => { routeEditState[name].splice(i, 1); renderRouteEditor(name); @@ -949,7 +959,7 @@ function renderRouteEditor(name) { async function addRouteEditRow(name) { if (!routeEditState[name]) routeEditState[name] = []; - routeEditState[name].push({ domain: "", upstream: "" }); + routeEditState[name].push({ domain: "", upstream: "", path: "" }); renderRouteEditor(name); } @@ -962,7 +972,11 @@ async function saveRouteEditor(name) { } try { setStatus(`Saving routes for ${name}...`); - const cleanRoutes = valid.map(r => ({ domain: r.domain.trim(), upstream: r.upstream.trim() })); + const cleanRoutes = valid.map(r => { + const route = { domain: r.domain.trim(), upstream: r.upstream.trim() }; + if (r.path && r.path.trim()) route.path = r.path.trim(); + return route; + }); await api.setRoutes(name, cleanRoutes); routeEditState[name] = cleanRoutes; renderRouteEditor(name); diff --git a/panel-api.py b/panel-api.py index cb63774..ae01dfd 100644 --- a/panel-api.py +++ b/panel-api.py @@ -351,11 +351,11 @@ class Handler(BaseHTTPRequestHandler): 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": ""}) + parts = entry.split("|", 2) + route = {"domain": parts[0].strip(), "upstream": parts[1].strip()} + if len(parts) > 2: + route["path"] = parts[2].strip() + routes.append(route) self._json(200, {"ok": True, "name": name, "routes": routes}) return @@ -589,8 +589,12 @@ class Handler(BaseHTTPRequestHandler): for r in payload["routes"]: d = r.get("domain", "").strip() u = r.get("upstream", "").strip() + p = r.get("path", "").strip() if d and u: - routes_parts.append(f"{d}|{u}") + if p: + routes_parts.append(f"{d}|{u}|{p}") + else: + routes_parts.append(f"{d}|{u}") elif "domain" in payload and "port" in payload: # Backward compat: single domain + port domain_str = payload.get("domain", "") @@ -813,10 +817,14 @@ class Handler(BaseHTTPRequestHandler): for r in route_list: d = r.get("domain", "").strip() u = r.get("upstream", "").strip() + p = r.get("path", "").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}") + if p: + routes_parts.append(f"{d}|{u}|{p}") + else: + 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) diff --git a/panelctl.sh b/panelctl.sh index 1f4c469..4490418 100644 --- a/panelctl.sh +++ b/panelctl.sh @@ -17,17 +17,18 @@ 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#*|}" + # Format: domain|upstream[/path] or domain|upstream (path is optional) + IFS='|' read -r domain upstream path <<< "${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}" + if [[ -n "${path}" ]]; then + [[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'" + fi } validate_routes() { @@ -351,12 +352,24 @@ cmd_render_route() { 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}" + IFS='|' read -r domain upstream path <<< "${entry}" + # If upstream is empty (no second pipe), this is the old format + if [[ -z "${upstream}" ]]; then + upstream="${path}" + path="" + fi + if [[ -n "${path}" ]]; then + if [[ -n "${auth_block}" ]]; then + printf "%s {\n%s reverse_proxy %s %s\n}\n" "${domain}" "${auth_block}" "${path}" "${upstream}" + else + printf "%s {\n reverse_proxy %s %s\n}\n" "${domain}" "${path}" "${upstream}" + fi else - printf "%s {\n reverse_proxy %s\n}\n" "${domain}" "${upstream}" + 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 fi done printf "# route:%s:end\n" "${name}"