/* * ipswap preset editor. * * Plain DOM code with no build step: the whole app is served from an embed.FS * inside a single .exe, and a Node toolchain in CI for a few hundred lines of * JavaScript is not a trade worth making. */ // The token arrives once in the query string. The server also sets it as a // SameSite=Strict cookie, but mutations additionally require it as a header — // a cookie alone would let any page in the browser POST to this port. const TOKEN = new URLSearchParams(location.search).get("t") || ""; const state = { presets: [], groups: [], adapters: [], settings: null, active: { active_id: "", active_name: "" }, editing: null, pendingApply: null, }; const $ = (id) => document.getElementById(id); // --- theme --- function applyTheme(light) { document.documentElement.classList.toggle("light", light); $("btn-theme").textContent = light ? "Dark" : "Light"; localStorage.setItem("ipswap-theme", light ? "light" : "dark"); } applyTheme(localStorage.getItem("ipswap-theme") === "light"); $("btn-theme").addEventListener("click", () => applyTheme(!document.documentElement.classList.contains("light")) ); // --- api --- async function api(path, opts = {}) { const headers = Object.assign({}, opts.headers); if (opts.method && opts.method !== "GET") { headers["X-Ipswap-Token"] = TOKEN; if (opts.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json"; } const res = await fetch(path, Object.assign({}, opts, { headers })); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { // The auth middleware writes plain text, not JSON. if (!res.ok) throw new Error(text || res.statusText); } if (!res.ok) { const err = new Error((data && data.error) || text || res.statusText); err.status = res.status; err.data = data; throw err; } return data; } // The server shuts down five minutes after the last one of these. setInterval(() => api("/api/heartbeat", { method: "POST" }).catch(() => {}), 30_000); // --- alerts --- function escapeHTML(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ); } // kind is one of ok, error, warn, info — matching apointless.css's alert // modifiers, with alert-ok supplied by ipswap.css. function alertBox(kind, message, container = "alerts") { const host = $(container); host.innerHTML = `
${escapeHTML(message)}
`; if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000); } function clearAlert(container) { $(container).innerHTML = ""; } // --- formatting --- function maskOf(prefix) { if (state.settings && state.settings.mask_style === "dotted") { const m = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; return `${m >>> 24}.${(m >>> 16) & 255}.${(m >>> 8) & 255}.${m & 255}`; } return "/" + prefix; } function addrText(a) { const m = maskOf(a.prefix); return m.startsWith("/") ? `${a.address}${m}` : `${a.address} ${m}`; } // --- rendering --- function renderStats() { $("stat-active").textContent = state.active.active_name || "unmatched"; $("stat-count").textContent = state.presets.length; $("stat-groups").textContent = state.groups.length; } function renderPresets() { const host = $("groups"); if (!state.presets.length) { host.innerHTML = `
No presets yet. Create one, or import a pack from a colleague.
`; return; } const byGroup = new Map(); for (const p of state.presets) { const g = p.group && p.group.trim() ? p.group : "Ungrouped"; if (!byGroup.has(g)) byGroup.set(g, []); byGroup.get(g).push(p); } let html = ""; for (const [group, presets] of byGroup) { html += `

${escapeHTML(group)}

${presets.length}
`; for (const p of presets) { const isDHCP = p.mode === "dhcp"; const isActive = p.id === state.active.active_id; const addr = isDHCP ? `DHCP` : escapeHTML(addrText(p.primary)) + (p.secondary && p.secondary.length ? ` +${p.secondary.length}` : ""); const dns = p.dns.mode === "dhcp" ? `from DHCP` : escapeHTML((p.dns.servers || []).join(", ")); html += ``; } html += `
NameAdapterAddressGatewayDNS
${isActive ? 'live' : ""} ${escapeHTML(p.name)}${p.notes ? `
${escapeHTML(p.notes)}
` : ""}
${escapeHTML(p.adapter)} ${addr} ${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")} ${dns}
`; } host.innerHTML = html; host.querySelectorAll("[data-apply]").forEach((b) => b.addEventListener("click", () => openConfirm(b.dataset.apply)) ); host.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => openEditor(b.dataset.edit)) ); host.querySelectorAll("[data-del]").forEach((b) => b.addEventListener("click", () => deletePreset(b.dataset.del)) ); } function renderAdapters() { const body = $("adapters"); if (!state.adapters.length) { body.innerHTML = `No adapters found.`; return; } body.innerHTML = state.adapters .map( (a) => ` ${escapeHTML(a.name)} ${escapeHTML(a.description || "")} ${escapeHTML(a.current || "—")} ${a.up ? 'up' : 'down'} ` ) .join(""); } // --- loading --- async function loadAll() { const [presets, adapters, settings, active] = await Promise.all([ api("/api/presets"), api("/api/adapters"), api("/api/settings"), api("/api/active"), ]); state.presets = presets.presets || []; state.groups = presets.groups || []; state.adapters = adapters.adapters || []; state.settings = settings; state.active = active || { active_id: "", active_name: "" }; renderStats(); renderPresets(); renderAdapters(); $("group-list").innerHTML = state.groups.map((g) => ``) .join(""); } // Keep the "live" marker honest without a reload: something outside ipswap can // change the network at any time. setInterval(async () => { try { const active = await api("/api/active"); if (active.active_id !== state.active.active_id) { state.active = active; renderStats(); renderPresets(); } } catch { // A dropped poll is not worth reporting; the next one will tell us. } }, 15_000); // --- apply --- const confirmDlg = $("confirm"); async function openConfirm(id) { const p = state.presets.find((x) => x.id === id); state.pendingApply = id; $("confirm-title").textContent = `Apply "${p ? p.name : id}"`; $("confirm-diff").textContent = "Reading the adapter…"; clearAlert("confirm-error"); $("confirm-go").disabled = true; confirmDlg.showModal(); try { // Read live at prompt time, exactly as the tray does. A stale CURRENT // block would undermine the only check before a destructive change. const prev = await api(`/api/presets/${encodeURIComponent(id)}/preview`); $("confirm-diff").textContent = prev.text; $("confirm-go").disabled = false; } catch (e) { $("confirm-diff").textContent = ""; alertBox("error", e.message, "confirm-error"); } } $("confirm-cancel").addEventListener("click", () => { state.pendingApply = null; confirmDlg.close(); }); $("confirm-go").addEventListener("click", async () => { const id = state.pendingApply; if (!id) return; const btn = $("confirm-go"); btn.disabled = true; btn.innerHTML = ` Applying…`; clearAlert("confirm-error"); try { const active = await api(`/api/presets/${encodeURIComponent(id)}/apply`, { method: "POST" }); state.active = active; confirmDlg.close(); await loadAll(); alertBox("ok", `Applied "${active.active_name || "preset"}".`); } catch (e) { // The server answers 409 when netsh refused for lack of an elevated token, // which has a specific next step rather than being a generic failure. if (e.data && e.data.elevation_required) { alertBox( "warn", "This needs administrator rights. Use \"Relaunch as administrator\" in the tray menu, then try again.", "confirm-error" ); } else { alertBox("error", e.message, "confirm-error"); } btn.disabled = false; } finally { btn.textContent = "Apply"; } }); // --- editor --- const editor = $("editor"); function secondaryRow(addr = "", prefix = "") { const div = document.createElement("div"); div.className = "sec-row"; div.innerHTML = ` `; div.querySelector(".sec-del").addEventListener("click", () => div.remove()); return div; } function openEditor(id) { const p = id ? state.presets.find((x) => x.id === id) : null; state.editing = p ? p.id : null; $("editor-title").textContent = p ? "Edit preset" : "New preset"; clearAlert("editor-error"); $("f-name").value = p ? p.name : ""; $("f-group").value = p ? p.group || "" : ""; $("f-adapter").value = p ? p.adapter : (state.adapters[0] || {}).name || ""; $("f-mode").value = p ? p.mode : "static"; $("f-notes").value = p ? p.notes || "" : ""; const prim = (p && p.primary) || {}; $("f-address").value = prim.address || ""; $("f-prefix").value = prim.prefix !== undefined ? maskOf(prim.prefix) : ""; $("f-gateway").value = prim.gateway || ""; $("f-metric").value = prim.gateway_metric || 0; const secs = $("secondaries"); secs.innerHTML = ""; for (const s of (p && p.secondary) || []) secs.appendChild(secondaryRow(s.address, maskOf(s.prefix))); $("f-dns-mode").value = p ? p.dns.mode : "static"; $("f-dns").value = p ? (p.dns.servers || []).join(", ") : ""; syncModeVisibility(); editor.showModal(); } function syncModeVisibility() { $("static-fields").hidden = $("f-mode").value === "dhcp"; $("dns-servers-field").hidden = $("f-dns-mode").value !== "static"; } $("f-mode").addEventListener("change", syncModeVisibility); $("f-dns-mode").addEventListener("change", syncModeVisibility); $("btn-add-sec").addEventListener("click", () => $("secondaries").appendChild(secondaryRow())); $("btn-new").addEventListener("click", () => openEditor(null)); $("btn-cancel").addEventListener("click", () => editor.close()); // Masks are parsed here so both spellings are accepted client-side, mirroring // ParsePrefix in Go. Malformed input is caught before the request goes out. function parsePrefix(s) { s = (s || "").trim().replace(/^\//, ""); if (!s) throw new Error("mask is required"); if (s.includes(".")) { const parts = s.split(".").map(Number); if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { throw new Error(`"${s}" is not a dotted-quad netmask`); } const m = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; let ones = 0; while (ones < 32 && m & (1 << (31 - ones))) ones++; if (ones < 32 && (m << ones) >>> 0) throw new Error(`"${s}" is not a contiguous netmask`); return ones; } const n = Number(s); if (!Number.isInteger(n) || n < 0 || n > 32) throw new Error(`"${s}" is not a prefix length`); return n; } async function savePreset() { const mode = $("f-mode").value; const dnsMode = $("f-dns-mode").value; let body; try { body = { id: state.editing || "", name: $("f-name").value.trim(), group: $("f-group").value.trim(), adapter: $("f-adapter").value, mode, notes: $("f-notes").value.trim(), dns: { mode: dnsMode, servers: dnsMode === "static" ? $("f-dns").value.split(",").map((s) => s.trim()).filter(Boolean) : undefined, }, }; if (mode === "static") { body.primary = { address: $("f-address").value.trim(), prefix: parsePrefix($("f-prefix").value), gateway: $("f-gateway").value.trim() || undefined, gateway_metric: Number($("f-metric").value) || undefined, }; const secs = []; for (const row of document.querySelectorAll("#secondaries .sec-row")) { const a = row.querySelector(".sec-addr").value.trim(); if (!a) continue; secs.push({ address: a, prefix: parsePrefix(row.querySelector(".sec-prefix").value) }); } if (secs.length) body.secondary = secs; } } catch (e) { alertBox("error", e.message, "editor-error"); return; } try { const path = state.editing ? `/api/presets/${encodeURIComponent(state.editing)}` : "/api/presets"; await api(path, { method: state.editing ? "PUT" : "POST", body: JSON.stringify(body) }); editor.close(); await loadAll(); alertBox("ok", "Preset saved."); } catch (e) { alertBox("error", e.message, "editor-error"); } } $("btn-save").addEventListener("click", savePreset); async function deletePreset(id) { const p = state.presets.find((x) => x.id === id); if (!confirm(`Delete preset "${p ? p.name : id}"?`)) return; try { await api(`/api/presets/${encodeURIComponent(id)}`, { method: "DELETE" }); await loadAll(); alertBox("ok", "Preset deleted."); } catch (e) { alertBox("error", e.message); } } // --- settings --- const settingsDlg = $("settings"); $("btn-settings").addEventListener("click", () => { const s = state.settings || {}; $("s-mask").value = s.mask_style || "prefix"; $("s-startup").checked = !!s.start_with_windows; $("s-updates").checked = !!s.check_updates; $("s-repo").value = s.update_repo || ""; settingsDlg.showModal(); }); $("btn-settings-cancel").addEventListener("click", () => settingsDlg.close()); $("btn-settings-save").addEventListener("click", async () => { try { await api("/api/settings", { method: "PUT", body: JSON.stringify({ mask_style: $("s-mask").value, start_with_windows: $("s-startup").checked, check_updates: $("s-updates").checked, update_repo: $("s-repo").value.trim(), }), }); settingsDlg.close(); await loadAll(); alertBox("ok", "Settings saved."); } catch (e) { alertBox("error", e.message); } }); // --- import / export --- $("btn-export").addEventListener("click", () => { // A plain navigation, so the browser's own download UI picks the location. location.href = "/api/export"; }); const importDlg = $("import"); $("btn-import").addEventListener("click", () => { clearAlert("import-error"); importDlg.showModal(); }); $("btn-import-cancel").addEventListener("click", () => importDlg.close()); $("btn-import-go").addEventListener("click", async () => { const file = $("i-file").files[0]; if (!file) { alertBox("error", "Choose a file first.", "import-error"); return; } const mode = $("i-mode").value; if (mode === "replace" && !confirm("Replace discards every preset you currently have. Continue?")) return; try { const res = await api(`/api/import?mode=${mode}`, { method: "POST", body: await file.text() }); importDlg.close(); await loadAll(); let msg = `Imported ${res.added} preset${res.added === 1 ? "" : "s"}.`; if (res.renamed && res.renamed.length) { msg += ` ${res.renamed.length} had an id collision and were kept alongside the originals.`; } alertBox("ok", msg); } catch (e) { alertBox("error", e.message, "import-error"); } }); // --- go --- loadAll().catch((e) => alertBox("error", `Could not load: ${e.message}`));