dashboard: route CRUD, source-aware service controls, XSS fix

- Add inline route management (+ route / x) per service in dashboard
- Expose service source (config vs api) in API response
- Only show service delete button for API-created services
- Pre-fill route port with service target_port
- Fix XSS in route path onclick handlers
- Skip renderServices refresh while route form is open (editingRoute guard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-23 08:58:14 +02:00
parent 64c4d146ec
commit fb89b78226
3 changed files with 71 additions and 5 deletions

View File

@@ -1086,7 +1086,10 @@ async function removeAllowlistDomain(domain) {
} catch (err) {} } catch (err) {}
} }
let editingRoute = false;
function renderServices(entries) { function renderServices(entries) {
if (editingRoute) return;
const el = document.getElementById('servicesList'); const el = document.getElementById('servicesList');
if (!entries.length) { if (!entries.length) {
el.innerHTML = '<div class="empty-state">No services configured</div>'; el.innerHTML = '<div class="empty-state">No services configured</div>';
@@ -1098,13 +1101,16 @@ function renderServices(entries) {
? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>' ? '<span class="lan-badge shared" title="Reachable from other devices on the network">LAN</span>'
: '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>') : '<span class="lan-badge local-only" title="Bound to localhost — not reachable from other devices. Start with 0.0.0.0 to share on LAN.">local only</span>')
: ''; : '';
const esc = s => s.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const routeLines = (e.routes || []).map(r => const routeLines = (e.routes || []).map(r =>
`<div class="service-port" style="color:var(--text-dim);">` + `<div class="service-port" style="color:var(--text-dim);display:flex;align-items:center;gap:0.3rem;">` +
`<span style="display:inline-block;min-width:60px;">${r.path}</span> ` + `<span style="display:inline-block;min-width:60px;">${r.path}</span> ` +
`&rarr; :${r.port}` + `&rarr; :${r.port}` +
(r.strip ? ` <span style="opacity:0.6;">(strip)</span>` : '') + (r.strip ? ` <span style="opacity:0.6;">(strip)</span>` : '') +
(e.name === 'numa' ? '' : ` <button class="btn-delete" onclick="deleteRoute('${esc(e.name)}','${esc(r.path)}')" title="Remove route" style="font-size:0.65rem;padding:0 0.25rem;min-width:auto;opacity:0.5;">&times;</button>`) +
`</div>` `</div>`
).join(''); ).join('');
const deletable = e.source !== 'config' && e.name !== 'numa';
return ` return `
<div class="service-item"> <div class="service-item">
<span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span> <span class="health-dot ${e.healthy ? 'up' : 'down'}" title="${e.healthy ? 'running' : 'not reachable'}"></span>
@@ -1112,12 +1118,52 @@ function renderServices(entries) {
<div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a>${lanBadge}</div> <div class="service-name"><a href="${e.url}" target="_blank">${e.name}.numa</a>${lanBadge}</div>
<div class="service-port">localhost:${e.target_port} &rarr; proxied</div> <div class="service-port">localhost:${e.target_port} &rarr; proxied</div>
${routeLines} ${routeLines}
${e.name === 'numa' ? '' : `<div style="margin-top:0.3rem;"><button onclick="toggleRouteForm('${e.name}')" style="font-size:0.7rem;padding:0.1rem 0.4rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">+ route</button><div id="routeForm-${e.name}" style="display:none;margin-top:0.3rem;"><div style="display:flex;gap:0.3rem;align-items:center;"><input type="text" id="routePath-${e.name}" placeholder="/path" style="flex:2;padding:0.25rem 0.4rem;font-size:0.75rem;"><input type="number" id="routePort-${e.name}" value="${e.target_port}" min="1" max="65535" style="flex:1;padding:0.25rem 0.4rem;font-size:0.75rem;"><label style="font-size:0.7rem;color:var(--text-dim);display:flex;align-items:center;gap:0.2rem;"><input type="checkbox" id="routeStrip-${e.name}">strip</label><button onclick="addRoute('${e.name}')" style="font-size:0.7rem;padding:0.2rem 0.5rem;background:var(--emerald);color:var(--bg);border:none;border-radius:4px;cursor:pointer;">add</button></div><div class="override-error" id="routeError-${e.name}" style="display:none;font-size:0.7rem;"></div></div></div>`}
</div> </div>
${e.name === 'numa' ? '' : `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>`} ${deletable ? `<button class="btn-delete" onclick="deleteService('${e.name}')" title="Remove service">&times;</button>` : ''}
</div> </div>
`}).join(''); `}).join('');
} }
function toggleRouteForm(name) {
const el = document.getElementById('routeForm-' + name);
const opening = el.style.display === 'none';
el.style.display = opening ? 'block' : 'none';
editingRoute = opening;
}
async function addRoute(name) {
const errEl = document.getElementById('routeError-' + name);
errEl.style.display = 'none';
try {
const path = document.getElementById('routePath-' + name).value.trim();
const port = parseInt(document.getElementById('routePort-' + name).value) || 0;
const strip = document.getElementById('routeStrip-' + name).checked;
const res = await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, port, strip }),
});
if (!res.ok) throw new Error(await res.text());
editingRoute = false;
refresh();
} catch (err) {
errEl.textContent = err.message;
errEl.style.display = 'block';
}
}
async function deleteRoute(name, path) {
try {
await fetch(API + '/services/' + encodeURIComponent(name) + '/routes', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
refresh();
} catch (err) { /* next refresh will update */ }
}
async function addService(event) { async function addService(event) {
event.preventDefault(); event.preventDefault();
const errEl = document.getElementById('serviceError'); const errEl = document.getElementById('serviceError');

View File

@@ -601,6 +601,7 @@ struct ServiceResponse {
lan_accessible: bool, lan_accessible: bool,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
routes: Vec<crate::service_store::RouteEntry>, routes: Vec<crate::service_store::RouteEntry>,
source: String,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -615,7 +616,19 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
store store
.list() .list()
.into_iter() .into_iter()
.map(|e| (e.name.clone(), e.target_port, e.routes.clone())) .map(|e| {
let source = if store.is_config_service(&e.name) {
"config"
} else {
"api"
};
(
e.name.clone(),
e.target_port,
e.routes.clone(),
source.to_string(),
)
})
.collect() .collect()
}; };
let tld = &ctx.proxy_tld; let tld = &ctx.proxy_tld;
@@ -624,7 +637,7 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
let check_futures: Vec<_> = entries let check_futures: Vec<_> = entries
.iter() .iter()
.map(|(_, port, _)| { .map(|(_, port, _, _)| {
let port = *port; let port = *port;
let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], port)); let localhost = std::net::SocketAddr::from(([127, 0, 0, 1], port));
let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port)); let lan_addr = lan_ip.map(|ip| std::net::SocketAddr::new(ip.into(), port));
@@ -644,13 +657,14 @@ async fn list_services(State(ctx): State<Arc<ServerCtx>>) -> Json<Vec<ServiceRes
.into_iter() .into_iter()
.zip(check_results) .zip(check_results)
.map( .map(
|((name, port, routes), (healthy, lan_accessible))| ServiceResponse { |((name, port, routes, source), (healthy, lan_accessible))| ServiceResponse {
url: format!("http://{}.{}", name, tld), url: format!("http://{}.{}", name, tld),
name, name,
target_port: port, target_port: port,
healthy, healthy,
lan_accessible, lan_accessible,
routes, routes,
source,
}, },
) )
.collect(); .collect();
@@ -701,6 +715,7 @@ async fn create_service(
healthy, healthy,
lan_accessible, lan_accessible,
routes: Vec::new(), routes: Vec::new(),
source: "api".to_string(),
}), }),
)) ))
} }

View File

@@ -143,6 +143,11 @@ impl ServiceStore {
removed removed
} }
/// Names are always stored lowercased, so callers must pass lowercase keys.
pub fn is_config_service(&self, name: &str) -> bool {
self.config_services.contains(name)
}
pub fn list(&self) -> Vec<&ServiceEntry> { pub fn list(&self) -> Vec<&ServiceEntry> {
let mut entries: Vec<_> = self.entries.values().collect(); let mut entries: Vec<_> = self.entries.values().collect();
entries.sort_by(|a, b| a.name.cmp(&b.name)); entries.sort_by(|a, b| a.name.cmp(&b.name));