Add Transparent Proxy and PROXY v2 support and help page
This commit is contained in:
@@ -27,7 +27,7 @@ def _clients():
|
||||
def graph_get():
|
||||
conn = db.get()
|
||||
nodes = [dict(r) for r in conn.execute(
|
||||
"SELECT id, type, client_id, host, port, proto, label, iface, x, y FROM nodes ORDER BY id")]
|
||||
"SELECT id, type, client_id, host, port, proto, label, iface, origin, x, y FROM nodes ORDER BY id")]
|
||||
links = [{"from": r["from_node"], "to": r["to_node"]} for r in conn.execute(
|
||||
"SELECT from_node, to_node FROM links")]
|
||||
return jsonify(nodes=nodes, links=links, clients=_clients(), interfaces=_interfaces())
|
||||
@@ -58,7 +58,7 @@ def graph_put():
|
||||
keep = {n["id"] for n in nodes if n["id"] > 0}
|
||||
for nid in existing - keep:
|
||||
conn.execute("DELETE FROM nodes WHERE id = ?", (nid,))
|
||||
cols = ("type", "client_id", "host", "port", "proto", "label", "iface", "x", "y")
|
||||
cols = ("type", "client_id", "host", "port", "proto", "label", "iface", "origin", "x", "y")
|
||||
for n in nodes:
|
||||
vals = [n[c] for c in cols]
|
||||
if n["id"] > 0 and n["id"] in existing:
|
||||
|
||||
@@ -34,10 +34,11 @@ def connect(path):
|
||||
def init(path):
|
||||
conn = connect(path)
|
||||
conn.executescript(schema_sql())
|
||||
# Databases from before tunnel nodes lack nodes.iface (same migration as db.c).
|
||||
# Older databases lack nodes.iface / nodes.origin (same migrations as db.c).
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(nodes)")}
|
||||
if "iface" not in cols:
|
||||
conn.execute("ALTER TABLE nodes ADD COLUMN iface TEXT NOT NULL DEFAULT ''")
|
||||
for col in ("iface", "origin"):
|
||||
if col not in cols:
|
||||
conn.execute(f"ALTER TABLE nodes ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -68,6 +68,7 @@ h3 { font-size: 14px; margin: 18px 0 8px; }
|
||||
.btn.primary:hover { filter: brightness(1.1); }
|
||||
.btn.danger { color: #ff8a8a; }
|
||||
.btn.small { padding: 3px 10px; font-size: 12.5px; }
|
||||
.btn[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
input, select, textarea {
|
||||
font: inherit; color: var(--text-primary); background: var(--surface-1);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; width: 100%;
|
||||
@@ -138,6 +139,10 @@ input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; te
|
||||
#wires .wire-del path { stroke: #fff; stroke-width: 2; pointer-events: none; }
|
||||
#wires .wire-del:hover circle { fill: var(--danger); }
|
||||
#canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
||||
.marquee {
|
||||
position: absolute; z-index: 5; pointer-events: none;
|
||||
border: 1px solid #f08a24; background: rgba(240, 138, 36, .15); border-radius: 2px;
|
||||
}
|
||||
.editor-hint {
|
||||
position: absolute; left: 12px; bottom: 10px; color: var(--text-muted); font-size: 12px;
|
||||
background: rgba(26, 26, 25, .85); padding: 4px 8px; border-radius: 4px; pointer-events: none;
|
||||
@@ -168,6 +173,11 @@ input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; te
|
||||
.node-body label { flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; }
|
||||
.node-body label > span { flex: none; width: 54px; }
|
||||
.node-body input, .node-body select { padding: 3px 6px; font-size: 12.5px; }
|
||||
.node-body .origin { display: flex; flex-direction: column; gap: 4px; }
|
||||
.node-body label.check { justify-content: flex-start; cursor: pointer; }
|
||||
.node-body label.check > span { width: auto; }
|
||||
.node-body label.check input { width: auto; flex: none; margin: 0; accent-color: var(--accent); }
|
||||
.node-body label.check.disabled { opacity: 0.5; cursor: default; }
|
||||
.node-status { font-size: 11.5px; color: var(--text-muted); min-height: 16px; display: flex; gap: 6px; align-items: center; }
|
||||
.node-status.err { color: #ff8a8a; }
|
||||
.socket {
|
||||
@@ -193,6 +203,26 @@ input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; te
|
||||
.save-state { color: var(--text-muted); font-size: 12.5px; min-width: 120px; }
|
||||
.save-state.err { color: #ff8a8a; }
|
||||
|
||||
/* Help */
|
||||
.help-toc { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 13.5px; }
|
||||
.help { line-height: 1.55; }
|
||||
.help p, .help ul, .help ol { margin: 0 0 10px; }
|
||||
.help p:last-child, .help ul:last-child, .help ol:last-child { margin-bottom: 0; }
|
||||
.help li + li { margin-top: 6px; }
|
||||
.help-table { border-collapse: collapse; width: 100%; margin-bottom: 10px; }
|
||||
.help-table td { padding: 7px 10px 7px 0; border-top: 1px solid var(--border); vertical-align: top; }
|
||||
.help-table td:first-child { white-space: nowrap; font-weight: 600; width: 1%; }
|
||||
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; }
|
||||
.swatch.client_source { background: var(--node-source); }
|
||||
.swatch.client_sink { background: var(--node-csink); }
|
||||
.swatch.public_sink { background: var(--node-psink); }
|
||||
.swatch.splitter { background: var(--node-split); }
|
||||
.swatch.tunnel_source { background: var(--node-tsource); }
|
||||
.swatch.tunnel_sink { background: var(--node-tsink); }
|
||||
@media (max-width: 640px) {
|
||||
.help-table td:first-child { white-space: normal; }
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.filter-row { display: flex; gap: 16px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.filter-row label { min-width: 280px; flex: 1; }
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a href="{{ url_for('views.services') }}" class="{{ 'active' if ep == 'views.services' }}">Services</a>
|
||||
<a href="{{ url_for('views.stats') }}" class="{{ 'active' if ep == 'views.stats' }}">Stats</a>
|
||||
<a href="{{ url_for('views.settings') }}" class="{{ 'active' if ep == 'views.settings' }}">Settings</a>
|
||||
<a href="{{ url_for('views.help_page') }}" class="{{ 'active' if ep == 'views.help_page' }}">Help</a>
|
||||
</nav>
|
||||
{% block toolbar %}{% endblock %}
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="logout">
|
||||
|
||||
98
web/patchbay_web/templates/help.html
Normal file
98
web/patchbay_web/templates/help.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Help{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Help</h1>
|
||||
<nav class="help-toc" aria-label="Contents">
|
||||
<a href="#overview">Overview</a>
|
||||
<a href="#start">Getting started</a>
|
||||
<a href="#editor">Patch editor</a>
|
||||
<a href="#nodes">Node types</a>
|
||||
<a href="#origin">Visitor addresses</a>
|
||||
<a href="#pages">Services and Stats</a>
|
||||
<a href="#settings">Settings and login</a>
|
||||
<a href="#trouble">Status messages</a>
|
||||
</nav>
|
||||
|
||||
<section class="card help" id="overview">
|
||||
<h2>Overview</h2>
|
||||
<p>PatchBay forwards ports between Linux machines over SSH. One machine is the <strong>target</strong>: it runs this web UI and is reachable from the internet. Every other machine is a <strong>client</strong>: it keeps an SSH connection open to the target, so it needs no open ports and can sit behind NAT.</p>
|
||||
<p>You decide what goes where by wiring nodes on the Patch page, like cables on a patch panel. A <strong>source</strong> is a service you want to reach (for example a web server on a client), a <strong>sink</strong> is where it shows up (for example a public port on the target). All traffic passes through the target, which counts it for the Stats page.</p>
|
||||
</section>
|
||||
|
||||
<section class="card help" id="start">
|
||||
<h2>Getting started</h2>
|
||||
<ol>
|
||||
<li>Install a client with <code>./install.sh --role client</code> and set <code>TargetHost</code> (and <code>TargetPort</code>) in <code>/etc/patchbay/patchbay.conf</code>.</li>
|
||||
<li>On the client, run <code>patchbayd --pubkey</code> and paste the output under <a href="{{ url_for('views.settings') }}">Settings</a> → Clients.</li>
|
||||
<li>Start <code>patchbayd</code> on the client. It shows as online on the Patch and Services pages within a few seconds.</li>
|
||||
<li>On the <a href="{{ url_for('views.patch') }}">Patch</a> page, add a Client Source for the service and a Public Sink for the port you want to open, then drag a wire from the source's output to the sink's input. That's it: the port is live as soon as the status line says "ready".</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="card help" id="editor">
|
||||
<h2>Patch editor</h2>
|
||||
<ul>
|
||||
<li><strong>Add</strong> creates a node in the middle of the view. Fill in its fields; changes are saved automatically and applied straight away.</li>
|
||||
<li><strong>Wiring:</strong> drag from an output socket (right side) to an input socket (left side). Grab a connected input to move its wire elsewhere. Click a wire and press its × button, or double-click it, to remove it.</li>
|
||||
<li><strong>Wire colours:</strong> blue is TCP, orange is UDP. A dashed wire means source and sink use different protocols and will not work.</li>
|
||||
<li><strong>Moving around:</strong> drag empty space to pan, scroll to zoom, <strong>Fit</strong> shows everything.</li>
|
||||
<li><strong>Selecting:</strong> click a node, or right-drag a box over several nodes (hold Shift to add to the selection). Drag the header of any selected node to move them all. Delete removes the selected nodes.</li>
|
||||
<li><strong>Tidy</strong> snaps all nodes to the grid and keeps them on it while it is switched on.</li>
|
||||
<li>Each sink shows its state at the bottom: live traffic and open connections, or what is wrong (see <a href="#trouble">Status messages</a>).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card help" id="nodes">
|
||||
<h2>Node types</h2>
|
||||
<table class="help-table">
|
||||
<tbody>
|
||||
<tr><td><span class="swatch client_source"></span>Client Source</td><td>A service on a client, e.g. <code>127.0.0.1:80</code> for a local web server. The address is as seen from that client, so it can also be another machine in the client's network.</td></tr>
|
||||
<tr><td><span class="swatch public_sink"></span>Public Sink</td><td>A port opened on the target. Bind <code>0.0.0.0</code> makes it reachable from everywhere, <code>127.0.0.1</code> only from the target itself.</td></tr>
|
||||
<tr><td><span class="swatch client_sink"></span>Client Sink</td><td>A port opened on a client. Use it to reach a service on one client from another client, without exposing it publicly.</td></tr>
|
||||
<tr><td><span class="swatch splitter"></span>Splitter</td><td>Sends one source to several sinks, e.g. the same service on a public port and on a client. A source has only one output, so use a splitter to fan out.</td></tr>
|
||||
<tr><td><span class="swatch tunnel_source"></span>Tunnel Source</td><td>A host behind a VPN interface (<code>tun0</code> etc.), on the target or on a client. Enter the interface and the peer's address inside the VPN.</td></tr>
|
||||
<tr><td><span class="swatch tunnel_sink"></span>Tunnel Sink</td><td>A port that only accepts connections arriving through a VPN interface, so VPN peers can reach a service that is not open anywhere else.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Label is a free-text name shown in Stats. Interface suggestions come from the tun interfaces each host reports.</p>
|
||||
</section>
|
||||
|
||||
<section class="card help" id="origin">
|
||||
<h2>Visitor addresses</h2>
|
||||
<p>Normally a service behind PatchBay sees every visitor as coming from PatchBay itself (for example <code>127.0.0.1</code>). Every sink has two optional checkboxes to pass the real address on. Only one can be on at a time.</p>
|
||||
<ul>
|
||||
<li><strong>PROXY v2</strong> puts a small header with the visitor's address in front of each connection. The service must understand the PROXY protocol, otherwise it sees garbage and drops the connection. Examples: Apache with <code>mod_remoteip</code> and <code>RemoteIPProxyProtocol On</code>, nginx with <code>listen ... proxy_protocol</code>, HAProxy, Postfix, Dovecot. For UDP the header is in front of every datagram.</li>
|
||||
<li><strong>Transparent source spoofing</strong> makes the connection arrive from the visitor's own address, so any program sees it without configuration. The source must be on a client, and the service should listen on a loopback address like <code>127.0.0.1</code>. PatchBay sets up the routing this needs on the client by itself (setting <code>TransparentTable</code>, default 470). If the visitor uses IPv6 and the service only IPv4, that connection falls back to the normal address.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card help" id="pages">
|
||||
<h2>Services and Stats</h2>
|
||||
<p><a href="{{ url_for('views.services') }}">Services</a> lists the listening ports on every host with the program behind them, which helps to fill in source addresses. Use Refresh now to ask all hosts for a fresh list.</p>
|
||||
<p><a href="{{ url_for('views.stats') }}">Stats</a> shows traffic per sink: current rates, open connections and history from the last hour up to all time. "In" is traffic towards the service, "out" is traffic back to the visitor. How long history is kept is set under Settings.</p>
|
||||
</section>
|
||||
|
||||
<section class="card help" id="settings">
|
||||
<h2>Settings and login</h2>
|
||||
<ul>
|
||||
<li>Logging in takes your password plus a 6-digit code sent by email. Too many failed attempts block your address for a while.</li>
|
||||
<li>All users can edit the patch and add or remove clients. The <strong>sysop</strong> (the account in <code>patchbay.conf</code>) also manages users, the mail server and statistics retention.</li>
|
||||
<li>Removing a client disconnects it at once; its nodes stay in the patch without a client until you pick another one.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card help" id="trouble">
|
||||
<h2>Status messages</h2>
|
||||
<table class="help-table">
|
||||
<tbody>
|
||||
<tr><td>not connected</td><td>The sink has no wire into its input.</td></tr>
|
||||
<tr><td>source offline</td><td>The client with the source is not connected right now. Check that <code>patchbayd</code> runs there and its key is listed under Settings.</td></tr>
|
||||
<tr><td>protocol mismatch</td><td>Source and sink use different protocols (TCP vs UDP).</td></tr>
|
||||
<tr><td>bind ... failed</td><td>The port is already used by another program, or the bind address does not exist on that host.</td></tr>
|
||||
<tr><td>interface not present</td><td>The tun interface does not exist (yet). PatchBay retries every few seconds, so this clears once the VPN is up.</td></tr>
|
||||
<tr><td>transparent spoofing needs a source on a client</td><td>Spoofing only works when the service is reached from a client. Use PROXY v2 instead, or move the source.</td></tr>
|
||||
<tr><td>daemon not reachable</td><td>The web UI cannot talk to <code>patchbayd</code> on the target. Changes are saved and applied once it runs again.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -15,6 +15,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn" id="fit-btn" title="Fit all nodes into view">Fit</button>
|
||||
<button type="button" class="btn" id="tidy-btn" aria-pressed="false" title="Snap all nodes to the grid">Tidy</button>
|
||||
<span id="save-state" class="save-state"></span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -22,7 +23,7 @@
|
||||
<div id="editor" tabindex="0">
|
||||
<svg id="wires" aria-hidden="true"><g id="wire-layer"></g></svg>
|
||||
<div id="canvas"></div>
|
||||
<div class="editor-hint">Drag from an output socket to an input socket to patch. Drag empty space to pan, scroll to zoom. Click a wire and use its x button (or double-click it) to remove it; drag a wire off an input to reconnect it. Selected nodes are removed with Delete.</div>
|
||||
<div class="editor-hint">Drag from an output socket to an input socket to patch. Drag empty space to pan, scroll to zoom. Right-drag to select several nodes, then drag one of them to move them together. Click a wire and use its x button (or double-click it) to remove it; drag a wire off an input to reconnect it. Selected nodes are removed with Delete.</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
|
||||
@@ -13,6 +13,11 @@ NODE_TYPES = {"client_source", "client_sink", "public_sink", "splitter", "tunnel
|
||||
SOURCES = {"client_source", "tunnel_source"}
|
||||
HAS_OUTPUT = SOURCES | {"splitter"}
|
||||
HAS_INPUT = {"client_sink", "public_sink", "splitter", "tunnel_sink"}
|
||||
SINKS = HAS_INPUT - {"splitter"}
|
||||
# How a sink passes the peer address to the service. Whether "transparent"
|
||||
# has a source on a client is checked by the daemon (route error), so editing
|
||||
# the wiring never makes a save fail.
|
||||
ORIGINS = {"", "proxy_v2", "transparent"}
|
||||
IFACE_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,15}$")
|
||||
HOST_RE = re.compile(r"^[A-Za-z0-9.:_\-\[\]%]{0,255}$")
|
||||
NAME_RE = re.compile(r"^[A-Za-z0-9._\-]{1,64}$")
|
||||
@@ -106,9 +111,14 @@ def graph(data, client_ids):
|
||||
if proto not in ("tcp", "udp"):
|
||||
raise Invalid("protocol must be tcp or udp")
|
||||
label = str(n.get("label") or "")[:64]
|
||||
origin = n.get("origin") or ""
|
||||
if origin not in ORIGINS:
|
||||
raise Invalid("origin must be proxy_v2 or transparent")
|
||||
if t not in SINKS:
|
||||
origin = ""
|
||||
nodes.append({
|
||||
"id": nid, "type": t, "client_id": cid, "host": host, "port": _port(n.get("port")),
|
||||
"proto": proto, "label": label, "iface": iface,
|
||||
"proto": proto, "label": label, "iface": iface, "origin": origin,
|
||||
"x": float(n.get("x", 0)), "y": float(n.get("y", 0)),
|
||||
})
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ def patch():
|
||||
return render_template("patch.html")
|
||||
|
||||
|
||||
@bp.get("/help")
|
||||
@login_required
|
||||
def help_page():
|
||||
return render_template("help.html")
|
||||
|
||||
|
||||
@bp.get("/services")
|
||||
@login_required
|
||||
def services():
|
||||
|
||||
Reference in New Issue
Block a user