Add Transparent Proxy and PROXY v2 support and help page
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user