/* * ipswap preset editor. * * Plain ES modules-free JavaScript on purpose: the whole app is served from an * embed.FS inside a single .exe, and a build step for the front end would mean * a Node toolchain in CI for a few hundred lines of DOM code. */ // 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, editing: null, }; // --- theme --- const themeToggle = document.getElementById("theme-toggle"); function applyTheme(light) { document.documentElement.classList.toggle("light", light); themeToggle.textContent = light ? "Dark" : "Light"; localStorage.setItem("ipswap-theme", light ? "light" : "dark"); } applyTheme(localStorage.getItem("ipswap-theme") === "light"); themeToggle.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 { // Non-JSON bodies come from the auth middleware, which writes plain text. if (!res.ok) throw new Error(text || res.statusText); } if (!res.ok) throw new Error((data && data.error) || text || res.statusText); return data; } // The server shuts down after five minutes without one of these. setInterval(() => { api("/api/heartbeat", { method: "POST" }).catch(() => {}); }, 30_000); // --- alerts --- function alertBox(kind, message, container = "alerts") { const host = document.getElementById(container); host.innerHTML = `
${escapeHTML(message)}
`; if (kind === "ok") setTimeout(() => (host.innerHTML = ""), 4000); } function escapeHTML(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ); } // --- rendering --- function maskOf(prefix) { if (state.settings && state.settings.mask_style === "dotted") { let m = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; return [m >>> 24, (m >>> 16) & 255, (m >>> 8) & 255, m & 255].join("."); } return "/" + prefix; } function addrText(a) { return a.address + maskOf(a.prefix).replace(/^(?!\/)/, " "); } function renderStats() { const adapters = new Set(state.presets.map((p) => p.adapter)); document.getElementById("stats").innerHTML = `
Presets
${state.presets.length}
Groups
${state.groups.length}
Adapters in use
${adapters.size}
`; } function renderPresets() { const host = document.getElementById("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 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
${escapeHTML(p.name)}${p.notes ? `
${escapeHTML(p.notes)}
` : ""}
${escapeHTML(p.adapter)} ${addr} ${isDHCP ? "" : escapeHTML(p.primary.gateway || "—")} ${dns}
`; } host.innerHTML = html; 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 = document.getElementById("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] = await Promise.all([ api("/api/presets"), api("/api/adapters"), api("/api/settings"), ]); state.presets = presets.presets || []; state.groups = presets.groups || []; state.adapters = adapters.adapters || []; state.settings = settings; renderStats(); renderPresets(); renderAdapters(); document.getElementById("group-list").innerHTML = state.groups .map((g) => ``) .join(""); } // --- editor --- const editor = document.getElementById("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; document.getElementById("editor-title").textContent = p ? "Edit preset" : "New preset"; document.getElementById("editor-error").innerHTML = ""; document.getElementById("f-name").value = p ? p.name : ""; document.getElementById("f-group").value = p ? p.group || "" : ""; document.getElementById("f-adapter").value = p ? p.adapter : (state.adapters[0] || {}).name || ""; document.getElementById("f-mode").value = p ? p.mode : "static"; document.getElementById("f-notes").value = p ? p.notes || "" : ""; const prim = (p && p.primary) || {}; document.getElementById("f-address").value = prim.address || ""; document.getElementById("f-prefix").value = prim.prefix !== undefined ? maskOf(prim.prefix) : ""; document.getElementById("f-gateway").value = prim.gateway || ""; document.getElementById("f-metric").value = prim.gateway_metric || 0; const secs = document.getElementById("secondaries"); secs.innerHTML = ""; for (const s of (p && p.secondary) || []) secs.appendChild(secondaryRow(s.address, maskOf(s.prefix))); document.getElementById("f-dns-mode").value = p ? p.dns.mode : "static"; document.getElementById("f-dns").value = p ? (p.dns.servers || []).join(", ") : ""; syncModeVisibility(); editor.showModal(); } function syncModeVisibility() { const isDHCP = document.getElementById("f-mode").value === "dhcp"; document.getElementById("static-fields").classList.toggle("hidden", isDHCP); const dnsStatic = document.getElementById("f-dns-mode").value === "static"; document.getElementById("dns-servers-field").classList.toggle("hidden", !dnsStatic); } document.getElementById("f-mode").addEventListener("change", syncModeVisibility); document.getElementById("f-dns-mode").addEventListener("change", syncModeVisibility); document.getElementById("btn-add-sec").addEventListener("click", () => document.getElementById("secondaries").appendChild(secondaryRow()) ); document.getElementById("btn-new").addEventListener("click", () => openEditor(null)); document.getElementById("btn-cancel").addEventListener("click", () => editor.close()); // Masks are parsed here so the editor accepts both spellings, matching the Go // side's ParsePrefix. Anything malformed is reported before the request goes // out rather than coming back as a server-side validation error. 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 = document.getElementById("f-mode").value; const dnsMode = document.getElementById("f-dns-mode").value; let body; try { body = { id: state.editing || "", name: document.getElementById("f-name").value.trim(), group: document.getElementById("f-group").value.trim(), adapter: document.getElementById("f-adapter").value, mode, notes: document.getElementById("f-notes").value.trim(), dns: { mode: dnsMode, servers: dnsMode === "static" ? document.getElementById("f-dns").value.split(",").map((s) => s.trim()).filter(Boolean) : undefined, }, }; if (mode === "static") { body.primary = { address: document.getElementById("f-address").value.trim(), prefix: parsePrefix(document.getElementById("f-prefix").value), gateway: document.getElementById("f-gateway").value.trim() || undefined, gateway_metric: Number(document.getElementById("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("danger", 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("danger", e.message, "editor-error"); } } document.getElementById("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("danger", e.message); } } // --- settings --- const settingsDlg = document.getElementById("settings"); document.getElementById("btn-settings").addEventListener("click", () => { const s = state.settings || {}; document.getElementById("s-mask").value = s.mask_style || "prefix"; document.getElementById("s-startup").checked = !!s.start_with_windows; document.getElementById("s-updates").checked = !!s.check_updates; document.getElementById("s-repo").value = s.update_repo || ""; settingsDlg.showModal(); }); document.getElementById("btn-settings-cancel").addEventListener("click", () => settingsDlg.close()); document.getElementById("btn-settings-save").addEventListener("click", async () => { try { await api("/api/settings", { method: "PUT", body: JSON.stringify({ mask_style: document.getElementById("s-mask").value, start_with_windows: document.getElementById("s-startup").checked, check_updates: document.getElementById("s-updates").checked, update_repo: document.getElementById("s-repo").value.trim(), }), }); settingsDlg.close(); await loadAll(); alertBox("ok", "Settings saved."); } catch (e) { alertBox("danger", e.message); } }); // --- import / export --- document.getElementById("btn-export").addEventListener("click", () => { // A plain navigation, so the browser's own download UI picks the location. location.href = "/api/export"; }); const importDlg = document.getElementById("import"); document.getElementById("btn-import").addEventListener("click", () => { document.getElementById("import-error").innerHTML = ""; importDlg.showModal(); }); document.getElementById("btn-import-cancel").addEventListener("click", () => importDlg.close()); document.getElementById("btn-import-go").addEventListener("click", async () => { const file = document.getElementById("i-file").files[0]; if (!file) { alertBox("danger", "Choose a file first.", "import-error"); return; } const mode = document.getElementById("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("danger", e.message, "import-error"); } }); // --- go --- loadAll().catch((e) => alertBox("danger", `Could not load: ${e.message}`));