/*
* 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 += ``;
}
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("");
$("f-adapter").innerHTML = state.adapters
.map((a) => ` ${escapeHTML(a.name)} `)
.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 = `
Remove `;
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}`));