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 c021d5a0c8
commit 03ca0bcb28
3 changed files with 71 additions and 5 deletions

View File

@@ -1086,7 +1086,10 @@ async function removeAllowlistDomain(domain) {
} catch (err) {}
}
let editingRoute = false;
function renderServices(entries) {
if (editingRoute) return;
const el = document.getElementById('servicesList');
if (!entries.length) {
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 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 =>
`<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> ` +
`&rarr; :${r.port}` +
(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>`
).join('');
const deletable = e.source !== 'config' && e.name !== 'numa';
return `
<div class="service-item">
<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-port">localhost:${e.target_port} &rarr; proxied</div>
${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>
${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>
`}).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) {
event.preventDefault();
const errEl = document.getElementById('serviceError');