Add Transparent Proxy and PROXY v2 support and help page

This commit is contained in:
mueller_minki
2026-10-04 19:21:50 +02:00
parent aebb7206f4
commit b7481c2109
24 changed files with 864 additions and 70 deletions

View File

@@ -4,6 +4,7 @@
(() => {
const NODE_W = 220;
const SOCKET_Y = 47; // socket centre below the node's top edge
const GRID = 24; // matches the editor background
const TYPES = {
client_source: { title: "Client Source", input: false, output: true },
client_sink: { title: "Client Sink", input: true, output: false },
@@ -32,7 +33,8 @@
daemon: true,
pan: { x: 40, y: 40 },
zoom: 1,
sel: null, // {kind: "node", id} | {kind: "link", link}
sel: null, // {kind: "nodes", ids: Set} | {kind: "link", link}
tidy: false, // snap nodes to the grid
nextTemp: -1,
dirty: false,
saving: false,
@@ -79,6 +81,13 @@
const t = `translate(${S.pan.x}px, ${S.pan.y}px) scale(${S.zoom})`;
canvas.style.transform = t;
wireLayer.setAttribute("transform", `translate(${S.pan.x} ${S.pan.y}) scale(${S.zoom})`);
// The grid moves with the canvas so tidy nodes sit on its lines.
editor.style.backgroundSize = `${GRID * S.zoom}px ${GRID * S.zoom}px`;
editor.style.backgroundPosition = `${S.pan.x}px ${S.pan.y}px`;
}
function snap(v) {
return Math.round(v / GRID) * GRID;
}
function toCanvas(clientX, clientY) {
@@ -242,6 +251,40 @@
return sel;
}
// How a sink passes the peer address on. Both options are exclusive, and
// spoofing needs the source on a client (the daemon reports it otherwise).
function originChecks(n) {
const box = (label, value, title) => {
const inp = PB.el("input", { type: "checkbox" });
const lab = PB.el("label", { class: "check", title }, inp, PB.el("span", { text: label }));
inp.addEventListener("change", () => {
n.origin = inp.checked ? value : "";
update();
changed();
});
return { inp, lab, value };
};
const proxy = box("PROXY v2", "proxy_v2",
"Send a PROXY protocol v2 header with the peer address to the service (e.g. Apache mod_remoteip, nginx proxy_protocol)");
const spoof = box("Transparent source spoofing", "transparent",
"Connect to the service from the peer's own address (IP_TRANSPARENT); the source must be on a client");
function update() {
const src = sourceOf(n.id);
const onClient = !src || !!src.client_id;
for (const b of [proxy, spoof]) {
b.inp.checked = n.origin === b.value;
let off = !!n.origin && !b.inp.checked;
if (b === spoof && !b.inp.checked && !onClient) off = true;
b.inp.disabled = off;
b.lab.classList.toggle("disabled", off);
}
}
update();
const wrap = PB.el("div", { class: "origin" }, proxy.lab, spoof.lab);
wrap._update = update;
return wrap;
}
function buildNode(n) {
const t = TYPES[n.type];
const el = PB.el("div", { class: `node ${n.type}` });
@@ -265,6 +308,11 @@
body.append(field("Port", portInput(n)), field("Proto", protoSelect(n)));
}
body.append(field("Label", textInput(n, "label", "optional")));
let origin = null;
if (t.input && n.type !== "splitter") {
origin = originChecks(n);
body.append(origin);
}
const status = PB.el("div", { class: "node-status" });
body.append(status);
el.append(head, body);
@@ -275,13 +323,15 @@
close.addEventListener("click", () => removeNode(n.id));
head.addEventListener("mousedown", (ev) => startNodeDrag(ev, n));
el.addEventListener("mousedown", (ev) => {
// Sockets must bubble up to the canvas handler that starts wire drags.
if (ev.target.closest("input, select, button, .socket")) return;
// Sockets must bubble up to the canvas handler that starts wire drags,
// right clicks to the editor that draws the selection box.
if (ev.button === 2 || ev.target.closest("input, select, button, .socket")) return;
ev.stopPropagation();
select({ kind: "node", id: n.id });
if (!isSelected(n.id)) selectNodes([n.id]);
});
el._status = status;
el._title = title;
el._origin = origin;
return el;
}
@@ -308,19 +358,23 @@
function addNode(type) {
const r = editor.getBoundingClientRect();
const c = toCanvas(r.left + r.width / 2, r.top + r.height / 2);
const off = (S.addOffset++ % 6) * 24;
const off = (S.addOffset++ % 6) * GRID;
const n = {
id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "",
id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "", origin: "",
iface: TUNNEL_TYPES.has(type) ? "tun0" : "",
host: type === "public_sink" ? "0.0.0.0" : (type === "splitter" || TUNNEL_TYPES.has(type)) ? "" : "127.0.0.1",
x: Math.round(c.x - NODE_W / 2 + off), y: Math.round(c.y - 80 + off),
};
if (S.tidy) {
n.x = snap(n.x);
n.y = snap(n.y);
}
S.nodes.set(n.id, n);
const el = buildNode(n);
S.els.set(n.id, el);
canvas.append(el);
placeNode(n);
select({ kind: "node", id: n.id });
selectNodes([n.id]);
changed();
}
@@ -330,17 +384,28 @@
if (el) el.remove();
S.els.delete(id);
S.links = S.links.filter((l) => l.from !== id && l.to !== id);
if (S.sel && S.sel.kind === "node" && S.sel.id === id) S.sel = null;
if (isSelected(id)) {
S.sel.ids.delete(id);
if (!S.sel.ids.size) S.sel = null;
}
drawWires();
changed();
}
function isSelected(id) {
return !!S.sel && S.sel.kind === "nodes" && S.sel.ids.has(id);
}
function select(sel) {
S.sel = sel;
for (const [id, el] of S.els) el.classList.toggle("selected", !!sel && sel.kind === "node" && sel.id === id);
for (const [id, el] of S.els) el.classList.toggle("selected", isSelected(id));
drawWires();
}
function selectNodes(ids) {
select(ids.length ? { kind: "nodes", ids: new Set(ids) } : null);
}
/* Status */
function updateStatus() {
@@ -348,6 +413,7 @@
const el = S.els.get(n.id);
if (!el) continue;
el._title.textContent = TYPES[n.type].title + (n.id > 0 ? ` #${n.id}` : "");
if (el._origin) el._origin._update();
const st = el._status;
st.replaceChildren();
st.classList.remove("err");
@@ -447,7 +513,10 @@
if (l.from === oldId) l.from = newId;
if (l.to === oldId) l.to = newId;
}
if (S.sel && S.sel.kind === "node" && S.sel.id === oldId) S.sel.id = newId;
if (isSelected(oldId)) {
S.sel.ids.delete(oldId);
S.sel.ids.add(newId);
}
any = true;
}
if (any) renderAll();
@@ -459,16 +528,26 @@
if (ev.button !== 0 || ev.target.closest("button")) return;
ev.preventDefault();
ev.stopPropagation();
select({ kind: "node", id: n.id });
// Dragging a member of a multi-selection moves the whole group.
if (!isSelected(n.id)) selectNodes([n.id]);
const group = [...S.sel.ids].map((id) => S.nodes.get(id)).filter(Boolean).map((g) => ({ g, x: g.x, y: g.y }));
const start = toCanvas(ev.clientX, ev.clientY);
const ox = n.x, oy = n.y;
let moved = false;
const move = (e) => {
const p = toCanvas(e.clientX, e.clientY);
n.x = Math.round(ox + p.x - start.x);
n.y = Math.round(oy + p.y - start.y);
let dx = Math.round(p.x - start.x), dy = Math.round(p.y - start.y);
// Tidy snaps the grabbed node; the others keep their offset to it.
if (S.tidy) {
dx = snap(ox + dx) - ox;
dy = snap(oy + dy) - oy;
}
for (const m of group) {
m.g.x = m.x + dx;
m.g.y = m.y + dy;
placeNode(m.g);
}
moved = true;
placeNode(n);
drawWires();
};
const up = () => {
@@ -543,7 +622,52 @@
}
});
// Right-drag draws a selection box; nodes touching it are selected.
// Shift keeps the current selection and adds to it.
function startMarquee(ev) {
ev.preventDefault();
editor.focus();
const r = editor.getBoundingClientRect();
const sx = ev.clientX - r.left, sy = ev.clientY - r.top;
const base = ev.shiftKey && S.sel && S.sel.kind === "nodes" ? [...S.sel.ids] : [];
const box = PB.el("div", { class: "marquee" });
editor.append(box);
const move = (e) => {
const x = e.clientX - r.left, y = e.clientY - r.top;
const x0 = Math.min(sx, x), y0 = Math.min(sy, y), w = Math.abs(x - sx), h = Math.abs(y - sy);
box.style.left = x0 + "px";
box.style.top = y0 + "px";
box.style.width = w + "px";
box.style.height = h + "px";
const a = toCanvas(r.left + x0, r.top + y0), b = toCanvas(r.left + x0 + w, r.top + y0 + h);
const ids = new Set(base);
for (const n of S.nodes.values()) {
const el = S.els.get(n.id);
const nh = el ? el.offsetHeight : 150;
if (n.x < b.x && n.x + NODE_W > a.x && n.y < b.y && n.y + nh > a.y) ids.add(n.id);
}
selectNodes([...ids]);
};
const up = (e) => {
if (e.button !== 2) return;
box.remove();
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
move(ev);
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
editor.addEventListener("contextmenu", (ev) => {
if (!ev.target.closest("input, select, textarea")) ev.preventDefault();
});
editor.addEventListener("mousedown", (ev) => {
if (ev.button === 2) {
if (!ev.target.closest("input, select, textarea")) startMarquee(ev);
return;
}
if (ev.button !== 0 && ev.button !== 1) return;
if (ev.target.closest(".node")) return;
ev.preventDefault();
@@ -581,7 +705,7 @@
if (ev.target.closest("input, select, textarea")) return;
if ((ev.key === "Delete" || ev.key === "Backspace") && S.sel) {
ev.preventDefault();
if (S.sel.kind === "node") removeNode(S.sel.id);
if (S.sel.kind === "nodes") [...S.sel.ids].forEach(removeNode);
else removeLink(S.sel.link);
}
});
@@ -603,6 +727,28 @@
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") setMenu(false); });
document.getElementById("fit-btn").addEventListener("click", fit);
// Tidy: snaps every node to the grid when switched on and keeps drags and
// new nodes on it. The switch is remembered per browser.
const tidyBtn = document.getElementById("tidy-btn");
function setTidy(on, apply) {
S.tidy = on;
tidyBtn.setAttribute("aria-pressed", String(on));
try { localStorage.setItem("pb-tidy", on ? "1" : "0"); } catch (e) { /* storage blocked */ }
if (!on || !apply) return;
let moved = false;
for (const n of S.nodes.values()) {
const x = snap(n.x), y = snap(n.y);
if (x === n.x && y === n.y) continue;
n.x = x;
n.y = y;
placeNode(n);
moved = true;
}
if (moved) changed();
}
tidyBtn.addEventListener("click", () => setTidy(!S.tidy, true));
try { setTidy(localStorage.getItem("pb-tidy") === "1", false); } catch (e) { /* storage blocked */ }
window.addEventListener("beforeunload", (ev) => {
if (S.dirty || S.saving) {
save();