added path to routing
This commit is contained in:
parent
7fd2531501
commit
85e29b8734
4 changed files with 84 additions and 30 deletions
23
API.md
23
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)
|
### Create app (wildcard domain)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -89,11 +103,14 @@ Note: Wildcard domains require DNS challenge configuration in Caddy.
|
||||||
{
|
{
|
||||||
"routes": [
|
"routes": [
|
||||||
{"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
|
{"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 <path> <upstream>` rule for sub-path routing.
|
||||||
|
|
||||||
Caddy reloads automatically via the systemd path watcher. Containers stay running.
|
Caddy reloads automatically via the systemd path watcher. Containers stay running.
|
||||||
|
|
||||||
### Save compose
|
### Save compose
|
||||||
|
|
@ -128,11 +145,13 @@ Caddy reloads automatically via the systemd path watcher. Containers stay runnin
|
||||||
"name": "myapp",
|
"name": "myapp",
|
||||||
"routes": [
|
"routes": [
|
||||||
{"domain": "app.srazka.com", "upstream": "127.0.0.1:18080"},
|
{"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
|
## Response format
|
||||||
|
|
||||||
All JSON responses include an `ok` boolean:
|
All JSON responses include an `ok` boolean:
|
||||||
|
|
|
||||||
|
|
@ -155,12 +155,14 @@
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.route-row input {
|
.route-row input {
|
||||||
flex: 1;
|
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
font-size: .85rem;
|
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 button { flex-shrink: 0; }
|
||||||
|
.route-row .arrow { color: var(--muted); font-weight: 600; 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; }
|
||||||
|
|
@ -307,13 +309,13 @@
|
||||||
<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>Routes <span style="font-weight:400;color:var(--muted);font-size:.78rem;">domain | upstream</span></label>
|
<label>Routes <span style="font-weight:400;color:var(--muted);font-size:.78rem;">domain→upstream path</span></label>
|
||||||
<div id="routeTable"></div>
|
<div id="routeTable"></div>
|
||||||
<div class="btn-group" style="margin-top:6px;">
|
<div class="btn-group" style="margin-top:6px;">
|
||||||
<button class="btn-sm" id="addRouteBtn">+ Add Route</button>
|
<button class="btn-sm" id="addRouteBtn">+ Add Route</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>. Upstream: <code>127.0.0.1:PORT</code>
|
Supports wildcards: <code>*.example.com</code>. Upstream: <code>127.0.0.1:PORT</code>. Optional path: <code>/_/*</code>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -436,7 +438,7 @@ function setStatus(msg, isError = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Route table input ───
|
// ─── Route table input ───
|
||||||
let createRoutes = [{ domain: "", upstream: "" }];
|
let createRoutes = [{ domain: "", upstream: "", path: "" }];
|
||||||
|
|
||||||
function renderRouteTable() {
|
function renderRouteTable() {
|
||||||
const table = document.getElementById("routeTable");
|
const table = document.getElementById("routeTable");
|
||||||
|
|
@ -446,12 +448,14 @@ function renderRouteTable() {
|
||||||
row.className = "route-row";
|
row.className = "route-row";
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
||||||
<span style="color:var(--muted);font-weight:600;">→</span>
|
<span class="arrow">→</span>
|
||||||
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
||||||
|
<input class="route-path" placeholder="/_/*" value="${escHtml(r.path || "")}" />
|
||||||
${createRoutes.length > 1 ? `<button class="btn-sm btn-danger" data-ridx="${i}">×</button>` : ""}
|
${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-domain").oninput = (e) => { createRoutes[i].domain = e.target.value; };
|
||||||
row.querySelector(".route-upstream").oninput = (e) => { createRoutes[i].upstream = 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]");
|
const delBtn = row.querySelector("[data-ridx]");
|
||||||
if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); };
|
if (delBtn) delBtn.onclick = () => { createRoutes.splice(i, 1); renderRouteTable(); };
|
||||||
table.appendChild(row);
|
table.appendChild(row);
|
||||||
|
|
@ -459,7 +463,7 @@ function renderRouteTable() {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("addRouteBtn").onclick = () => {
|
document.getElementById("addRouteBtn").onclick = () => {
|
||||||
createRoutes.push({ domain: "", upstream: "" });
|
createRoutes.push({ domain: "", upstream: "", path: "" });
|
||||||
renderRouteTable();
|
renderRouteTable();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -500,13 +504,17 @@ document.getElementById("createBtn").onclick = async () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus(`Creating ${name}...`);
|
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}.`);
|
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 = "";
|
||||||
createRoutes = [{ domain: "", upstream: "" }];
|
createRoutes = [{ domain: "", upstream: "", path: "" }];
|
||||||
renderRouteTable();
|
renderRouteTable();
|
||||||
await loadApps();
|
await loadApps();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -930,12 +938,14 @@ function renderRouteEditor(name) {
|
||||||
row.className = "route-row";
|
row.className = "route-row";
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
<input class="route-domain" placeholder="whoami.srazka.com" value="${escHtml(r.domain)}" />
|
||||||
<span style="color:var(--muted);font-weight:600;">→</span>
|
<span class="arrow">→</span>
|
||||||
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
<input class="route-upstream" placeholder="127.0.0.1:18080" value="${escHtml(r.upstream)}" />
|
||||||
|
<input class="route-path" placeholder="/_/*" value="${escHtml(r.path || "")}" />
|
||||||
<button class="btn-sm btn-danger" data-ridx="${i}">×</button>
|
<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-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-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 = () => {
|
row.querySelector("[data-ridx]").onclick = () => {
|
||||||
routeEditState[name].splice(i, 1);
|
routeEditState[name].splice(i, 1);
|
||||||
renderRouteEditor(name);
|
renderRouteEditor(name);
|
||||||
|
|
@ -949,7 +959,7 @@ function renderRouteEditor(name) {
|
||||||
|
|
||||||
async function addRouteEditRow(name) {
|
async function addRouteEditRow(name) {
|
||||||
if (!routeEditState[name]) routeEditState[name] = [];
|
if (!routeEditState[name]) routeEditState[name] = [];
|
||||||
routeEditState[name].push({ domain: "", upstream: "" });
|
routeEditState[name].push({ domain: "", upstream: "", path: "" });
|
||||||
renderRouteEditor(name);
|
renderRouteEditor(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -962,7 +972,11 @@ async function saveRouteEditor(name) {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setStatus(`Saving routes for ${name}...`);
|
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);
|
await api.setRoutes(name, cleanRoutes);
|
||||||
routeEditState[name] = cleanRoutes;
|
routeEditState[name] = cleanRoutes;
|
||||||
renderRouteEditor(name);
|
renderRouteEditor(name);
|
||||||
|
|
|
||||||
22
panel-api.py
22
panel-api.py
|
|
@ -351,11 +351,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
entry = entry.strip()
|
entry = entry.strip()
|
||||||
if not entry:
|
if not entry:
|
||||||
continue
|
continue
|
||||||
if "|" in entry:
|
parts = entry.split("|", 2)
|
||||||
domain, upstream = entry.split("|", 1)
|
route = {"domain": parts[0].strip(), "upstream": parts[1].strip()}
|
||||||
routes.append({"domain": domain.strip(), "upstream": upstream.strip()})
|
if len(parts) > 2:
|
||||||
else:
|
route["path"] = parts[2].strip()
|
||||||
routes.append({"domain": entry.strip(), "upstream": ""})
|
routes.append(route)
|
||||||
self._json(200, {"ok": True, "name": name, "routes": routes})
|
self._json(200, {"ok": True, "name": name, "routes": routes})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -589,8 +589,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
for r in payload["routes"]:
|
for r in payload["routes"]:
|
||||||
d = r.get("domain", "").strip()
|
d = r.get("domain", "").strip()
|
||||||
u = r.get("upstream", "").strip()
|
u = r.get("upstream", "").strip()
|
||||||
|
p = r.get("path", "").strip()
|
||||||
if d and u:
|
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:
|
elif "domain" in payload and "port" in payload:
|
||||||
# Backward compat: single domain + port
|
# Backward compat: single domain + port
|
||||||
domain_str = payload.get("domain", "")
|
domain_str = payload.get("domain", "")
|
||||||
|
|
@ -813,10 +817,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
for r in route_list:
|
for r in route_list:
|
||||||
d = r.get("domain", "").strip()
|
d = r.get("domain", "").strip()
|
||||||
u = r.get("upstream", "").strip()
|
u = r.get("upstream", "").strip()
|
||||||
|
p = r.get("path", "").strip()
|
||||||
if not d or not u:
|
if not d or not u:
|
||||||
self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"})
|
self._json(400, {"ok": False, "error": "each route needs 'domain' and 'upstream'"})
|
||||||
return
|
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)
|
routes_str = ",".join(routes_parts)
|
||||||
result = run_panelctl(["set-routes", name, routes_str])
|
result = run_panelctl(["set-routes", name, routes_str])
|
||||||
self._json(200 if result["ok"] else 400, result)
|
self._json(200 if result["ok"] else 400, result)
|
||||||
|
|
|
||||||
31
panelctl.sh
31
panelctl.sh
|
|
@ -17,17 +17,18 @@ FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
|
||||||
|
|
||||||
validate_route_entry() {
|
validate_route_entry() {
|
||||||
local entry="$1"
|
local entry="$1"
|
||||||
# Format: domain|upstream (upstream = host:port)
|
# Format: domain|upstream[/path] or domain|upstream (path is optional)
|
||||||
local domain="${entry%%|*}"
|
IFS='|' read -r domain upstream path <<< "${entry}"
|
||||||
local upstream="${entry#*|}"
|
|
||||||
[[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'"
|
[[ -n "${domain}" ]] || fail "empty domain in route entry '${entry}'"
|
||||||
[[ -n "${upstream}" ]] || fail "empty upstream 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_single_domain "${domain}"
|
||||||
# Validate upstream has a port
|
# Validate upstream has a port
|
||||||
local upstream_port="${upstream##*:}"
|
local upstream_port="${upstream##*:}"
|
||||||
[[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'"
|
[[ "${upstream_port}" =~ ^[0-9]+$ ]] || fail "upstream '${upstream}' missing numeric port in route entry '${entry}'"
|
||||||
validate_port "${upstream_port}"
|
validate_port "${upstream_port}"
|
||||||
|
if [[ -n "${path}" ]]; then
|
||||||
|
[[ "${path}" == /* ]] || fail "path '${path}' must start with / in route entry '${entry}'"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_routes() {
|
validate_routes() {
|
||||||
|
|
@ -351,12 +352,24 @@ cmd_render_route() {
|
||||||
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
|
IFS=',' read -ra route_entries <<< "${APP_ROUTES}"
|
||||||
for entry in "${route_entries[@]}"; do
|
for entry in "${route_entries[@]}"; do
|
||||||
entry="$(echo "${entry}" | xargs)"
|
entry="$(echo "${entry}" | xargs)"
|
||||||
local domain="${entry%%|*}"
|
IFS='|' read -r domain upstream path <<< "${entry}"
|
||||||
local upstream="${entry#*|}"
|
# If upstream is empty (no second pipe), this is the old format
|
||||||
if [[ -n "${auth_block}" ]]; then
|
if [[ -z "${upstream}" ]]; then
|
||||||
printf "%s {\n%s reverse_proxy %s\n}\n" "${domain}" "${auth_block}" "${upstream}"
|
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
|
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
|
fi
|
||||||
done
|
done
|
||||||
printf "# route:%s:end\n" "${name}"
|
printf "# route:%s:end\n" "${name}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue