// NAT: port forwards (with hairpin and IPv6 pinholes), source NAT and // redirects. import { h, mount, table, modal, errorBox, field, input, checkbox, confirmDialog, badge } from "../ui.js"; import { get, change, can } from "../api.js"; import { rerender } from "../app.js"; import { names, matchEditor, describe, hits, counters, listEditor, rowClass } from "../widgets.js"; export async function render(el) { const [fwds, snats, redirects, cs] = await Promise.all([get("/forwards"), get("/snats"), get("/redirects"), counters()]); const write = can("config", true); const actions = (kind, x, edit) => x.pending === "deleted" ? badge("deleted", "err") : write ? [ h("button", { class: "small reveal", onclick: () => edit(x) }, "Edit"), " ", h("button", { class: "small reveal", onclick: () => toggle(kind, x) }, x.disabled ? "Enable" : "Disable"), " ", h("button", { class: "small reveal", onclick: () => del(kind, x) }, "Delete")] : null; mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "NAT"), h("div", { class: "muted" }, "Port forwards publish inside services; source NAT hides inside addresses behind the firewall's; redirects send traffic to the firewall itself."))), h("div", { class: "panel" }, h("div", { class: "panel-head" }, h("h2", null, "Port forwards"), write ? h("button", { class: "primary small", onclick: () => forwardDialog(null) }, "Add port forward") : null), table([ { label: "Name", render: (f) => f.name || h("span", { class: "muted" }, "–") }, { label: "Public", render: (f) => h("span", { class: "mono" }, (f.public_addresses || []).join(", ") || "(external zones)", " ", f.protocols.join("+"), "/", f.ports) }, { label: "Inside", render: (f) => h("span", { class: "mono" }, (f.to || "") + (f.to_port ? ":" + f.to_port : ""), f.to6 ? h("div", null, f.to6) : null) }, { label: "From", render: (f) => (f.sources || []).join(", ") || h("span", { class: "muted" }, "anywhere") }, { label: "Hairpin", render: (f) => f.hairpin ? badge((f.hairpin_sources || []).length ? "from " + f.hairpin_sources.join(", ") : "yes", "ok") : "" }, { label: "Hits", render: (f) => hits(cs, "f" + f.id, "f" + f.id + "-6") }, { label: "", tdClass: "actions-cell", render: (f) => actions("forwards", f, forwardDialog) }, ], fwds, { empty: "No port forwards.", rowClass })), h("div", { class: "panel" }, h("div", { class: "panel-head" }, h("h2", null, "Source NAT"), write ? h("button", { class: "primary small", onclick: () => snatDialog(null) }, "Add source NAT") : null), table([ { label: "#", render: (x, i) => String(i + 1) }, { label: "Match", render: (x) => describe(x) }, { label: "To", render: (x) => x.to ? h("span", { class: "mono" }, x.to) : "masquerade" }, { label: "Hits", render: (x) => hits(cs, "s" + x.id) }, { label: "Comment", render: (x) => x.comment }, { label: "", tdClass: "actions-cell", render: (x) => actions("snats", x, snatDialog) }, ], snats, { empty: "No source NAT.", rowClass })), h("div", { class: "panel" }, h("div", { class: "panel-head" }, h("h2", null, "Redirects"), write ? h("button", { class: "primary small", onclick: () => redirectDialog(null) }, "Add redirect") : null), table([ { label: "Match", render: (x) => describe(x) }, { label: "To port", render: (x) => x.to_port || h("span", { class: "muted" }, "the service's") }, { label: "Hits", render: (x) => hits(cs, "d" + x.id) }, { label: "Comment", render: (x) => x.comment }, { label: "", tdClass: "actions-cell", render: (x) => actions("redirects", x, redirectDialog) }, ], redirects, { empty: "No redirects.", rowClass }))); } async function toggle(kind, x) { try { await change("PATCH", "/" + kind + "/" + x.id, { disabled: !x.disabled }); rerender(); } catch (e) { modal("Not changed", errorBox(e)); } } async function del(kind, x) { if (!await confirmDialog("Delete", "Delete " + (x.name || x.comment || "this " + kind.replace(/s$/, "")) + "?", "Delete", "danger")) return; try { await change("DELETE", "/" + kind + "/" + x.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); } } // dialog runs an add/edit dialog whose fields give value(). async function dialog(title, kind, x, fields, value) { const err = h("div"); const save = async (dry) => { mount(err); try { await change(x ? "PATCH" : "POST", x ? "/" + kind + "/" + x.id : "/" + kind, value(), { query: dry ? { dry_run: 1 } : undefined }); if (dry) return false; rerender(); return true; } catch (e) { mount(err, errorBox(e)); return false; } }; await modal(title, h("div", null, h("div", { class: "form-grid" }, fields), err), [ { label: "Cancel", value: null }, { label: "Check", onclick: () => save(true) }, { label: x ? "Save" : "Add", kind: "primary", onclick: () => save(false) }, ], { wide: true, sticky: true }); } async function forwardDialog(f) { const n = await names(); f = f || { protocols: ["tcp"], hairpin: true }; const name = input({ value: f.name || "", placeholder: "web" }); const tcp = checkbox("TCP", f.protocols.includes("tcp")), udp = checkbox("UDP", f.protocols.includes("udp")); const ports = input({ value: f.ports || "", placeholder: "8080, or a range: 27015-27030", class: "mono" }); const pub = listEditor(f.public_addresses, { suggestions: n.sets, placeholder: "your public IPv4 address, or a set", addLabel: "Add address" }); const to = input({ value: f.to || "", placeholder: "192.168.1.10", class: "mono" }); const toPort = input({ value: f.to_port || "", placeholder: "the same", class: "mono short" }); const to6 = input({ value: f.to6 || "", placeholder: "2001:db8:1::10 (optional)", class: "mono" }); const src = listEditor(f.sources, { suggestions: n.sets, placeholder: "anywhere", addLabel: "Add source" }); const hairpin = checkbox("the public address also works from inside (hairpin NAT)", f.hairpin); const hairSrc = listEditor(f.hairpin_sources, { suggestions: n.sets, placeholder: "all inside sources", addLabel: "Add source" }); const comment = input({ value: f.comment || "" }); await dialog(f.id ? "Edit port forward" : "Add port forward", "forwards", f.id ? f : null, [ field("Name", name), field("Protocols", h("div", null, tcp, udp)), field("Public port", ports, "A port or a range."), field("Public addresses", pub, "None: any address arriving on the external zones (hairpin then needs an address)."), field("Inside host", h("div", null, to, " port ", toPort), "IPv4: the connection is translated."), field("Inside IPv6 host", to6, "Optional: the port is also opened to this IPv6 host, on its own port (no translation)."), field("From", src, "Only these sources may connect."), field("Hairpin", h("div", null, hairpin, hairSrc)), field("Comment", comment), ], () => ({ name: name.value.trim(), protocols: [tcp.input.checked ? "tcp" : "", udp.input.checked ? "udp" : ""].filter(Boolean), ports: ports.value.trim(), public_addresses: pub.value(), to: to.value.trim(), to_port: toPort.value.trim(), to6: to6.value.trim(), sources: src.value(), hairpin: hairpin.input.checked, hairpin_sources: hairSrc.value(), comment: comment.value.trim() })); } async function snatDialog(x) { const n = await names(); x = x || {}; const match = matchEditor(x, n); const to = input({ value: x.to || "", placeholder: "masquerade (the outgoing interface's address)", class: "mono" }); const comment = input({ value: x.comment || "" }); await dialog(x.id ? "Edit source NAT" : "Add source NAT", "snats", x.id ? x : null, [ ...match.flat(), field("Translate to", to, "Empty: masquerade. IPv4 only unless the family is IPv6 (NAT66)."), field("Comment", comment), ], () => ({ ...match.value(), to: to.value.trim(), comment: comment.value.trim() })); } async function redirectDialog(x) { const n = await names(); x = x || {}; const match = matchEditor(x, n, { out: false }); const toPort = input({ value: x.to_port || "", placeholder: "the service's port", class: "mono short" }); const comment = input({ value: x.comment || "" }); await dialog(x.id ? "Edit redirect" : "Add redirect", "redirects", x.id ? x : null, [ ...match.flat(), field("To local port", toPort), field("Comment", comment), ], () => ({ ...match.value(), to_port: toPort.value.trim(), comment: comment.value.trim() })); }