Initial Awawawa

This commit is contained in:
mueller_minki
2026-10-04 09:08:27 +02:00
commit 8e83a98ef4
76 changed files with 8225 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
"""PatchBay web frontend (runs on the target only)."""
import datetime
import secrets
from flask import Flask, g
from . import api, auth, db, security, views
from .conf import Config
def prepare_config(conf):
"""Replaces a plaintext SysopPassword in the file with its hash."""
pw = conf.get("sysoppassword")
if pw and not security.is_hashed(pw):
conf.update({"SysopPassword": security.hash_password(pw)})
def create_app(conf_path, testing=False):
conf = Config(conf_path)
prepare_config(conf)
db.init(conf.get("database"))
app = Flask(__name__)
conn = db.connect(conf.get("database"))
row = conn.execute("SELECT value FROM settings WHERE key = 'secret_key'").fetchone()
if row:
secret = row[0]
else:
secret = secrets.token_hex(32)
conn.execute("INSERT INTO settings (key, value) VALUES ('secret_key', ?)", (secret,))
conn.close()
app.config.update(
PB_CONF=conf,
PB_DB=conf.get("database"),
SECRET_KEY=secret,
TESTING=testing,
SESSION_COOKIE_NAME="patchbay_session",
SESSION_COOKIE_SECURE=not testing,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Strict",
PERMANENT_SESSION_LIFETIME=datetime.timedelta(hours=conf.getint("sessionhours", 12)),
MAX_CONTENT_LENGTH=2 * 1024 * 1024,
)
app.register_blueprint(auth.bp)
app.register_blueprint(views.bp)
app.register_blueprint(api.bp)
app.teardown_appcontext(db.close)
@app.context_processor
def inject():
return {"csrf_token": auth.csrf_token, "user": g.get("user")}
@app.after_request
def headers(resp):
resp.headers["Content-Security-Policy"] = (
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["Referrer-Policy"] = "no-referrer"
if not testing:
resp.headers["Strict-Transport-Security"] = "max-age=31536000"
return resp
return app

View File

@@ -0,0 +1,60 @@
"""patchbay-web entry point.
python3 -m patchbay_web [-c conf] [serve] run the HTTPS web UI
python3 -m patchbay_web [-c conf] hashpw print a password hash
python3 -m patchbay_web [-c conf] add-client NAME add a client key read from stdin
"""
import argparse
import getpass
import ssl
import sys
from . import create_app, daemon, db, security, tls, validate
def main():
ap = argparse.ArgumentParser(prog="patchbay-web")
ap.add_argument("-c", "--config", default="/etc/patchbay/patchbay.conf")
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("serve")
sub.add_parser("hashpw")
ac = sub.add_parser("add-client")
ac.add_argument("name")
args = ap.parse_args()
if args.cmd == "hashpw":
pw = getpass.getpass("Password: ")
print(security.hash_password(pw))
return 0
app = create_app(args.config)
conf = app.config["PB_CONF"]
if args.cmd == "add-client":
try:
name = validate.name(args.name, "client name")
key = validate.pubkey(sys.stdin.read())
except validate.Invalid as e:
print(f"error: {e}", file=sys.stderr)
return 1
with app.app_context():
db.get().execute("INSERT INTO clients (name, pubkey, added_by, created) VALUES (?, ?, 'cli', ?)",
(name, key, db.now()))
print(daemon.reload())
return 0
cert, key = conf.get("tlscert"), conf.get("tlskey")
if tls.ensure_cert(cert, key):
print(f"generated self-signed certificate {cert}", file=sys.stderr)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(cert, key)
from werkzeug.serving import run_simple
run_simple(conf.get("webbind"), conf.getint("webport", 8443), app, ssl_context=ctx, threaded=True)
return 0
if __name__ == "__main__":
sys.exit(main())

123
web/patchbay_web/api.py Normal file
View File

@@ -0,0 +1,123 @@
"""JSON API used by the patch editor, services and stats pages."""
from flask import Blueprint, jsonify, request
from . import daemon, db, validate
from .auth import login_required
bp = Blueprint("api", __name__, url_prefix="/api")
RANGES = { # range -> (seconds, tier, bucket seconds)
"1h": (3600, "m", 60),
"24h": (86400, "m", 60),
"7d": (7 * 86400, "h", 3600),
"30d": (30 * 86400, "h", 3600),
"1y": (365 * 86400, "d", 86400),
"all": (None, "d", 86400),
}
def _clients():
rows = db.get().execute("SELECT id, name, hostname, last_seen, last_addr FROM clients ORDER BY name")
return [dict(r) for r in rows]
@bp.get("/graph")
@login_required
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")]
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())
def _interfaces():
"""{host id: [{name, addr}]}, host id 0 = target."""
out = {}
for r in db.get().execute("SELECT client_id, name, addr FROM interfaces ORDER BY client_id, name"):
out.setdefault(str(r["client_id"]), []).append({"name": r["name"], "addr": r["addr"]})
return out
@bp.put("/graph")
@login_required
def graph_put():
conn = db.get()
client_ids = {r["id"] for r in conn.execute("SELECT id FROM clients")}
try:
nodes, links = validate.graph(request.get_json(silent=True), client_ids)
except (validate.Invalid, TypeError, ValueError) as e:
return jsonify(ok=False, error=str(e)), 400
idmap = {}
conn.execute("BEGIN IMMEDIATE")
try:
existing = {r["id"] for r in conn.execute("SELECT id FROM nodes")}
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")
for n in nodes:
vals = [n[c] for c in cols]
if n["id"] > 0 and n["id"] in existing:
conn.execute(f"UPDATE nodes SET {', '.join(c + ' = ?' for c in cols)} WHERE id = ?",
vals + [n["id"]])
idmap[n["id"]] = n["id"]
else:
cur = conn.execute(f"INSERT INTO nodes ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})",
vals)
idmap[n["id"]] = cur.lastrowid
conn.execute("DELETE FROM links")
for a, b in links:
conn.execute("INSERT INTO links (from_node, to_node) VALUES (?, ?)", (idmap[a], idmap[b]))
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
res = daemon.reload()
return jsonify(ok=True, ids={str(k): v for k, v in idmap.items()}, daemon=res.get("ok", False),
daemon_error=res.get("error"))
@bp.get("/status")
@login_required
def status():
st = daemon.request("STATUS")
return jsonify(daemon=st.get("ok", False), error=st.get("error"), clients=st.get("clients", {}),
sinks=st.get("sinks", {}))
@bp.get("/live")
@login_required
def live():
st = daemon.request("LIVE")
return jsonify(daemon=st.get("ok", False), sinks=st.get("sinks", {}))
@bp.post("/services/refresh")
@login_required
def services_refresh():
res = daemon.request("SERVICES")
return jsonify(ok=res.get("ok", False), error=res.get("error"))
@bp.get("/stats")
@login_required
def stats():
rng = request.args.get("range", "24h")
if rng not in RANGES:
return jsonify(error="bad range"), 400
span, tier, step = RANGES[rng]
try:
sink = int(request.args.get("sink", "0"))
except ValueError:
return jsonify(error="bad sink"), 400
since = db.now() - span if span else 0
rows = db.get().execute(
"SELECT ts, bytes_in, bytes_out, conns FROM stats WHERE sink_id = ? AND tier = ? AND ts >= ? ORDER BY ts",
(sink, tier, since)).fetchall()
return jsonify(range=rng, step=step, since=since, now=db.now(),
points=[[r["ts"], r["bytes_in"], r["bytes_out"], r["conns"]] for r in rows])

192
web/patchbay_web/auth.py Normal file
View File

@@ -0,0 +1,192 @@
"""Login (password + emailed one-time code), sessions and CSRF protection."""
import functools
import hmac
import secrets
import time
from flask import (Blueprint, abort, current_app, flash, g, redirect, render_template, request,
session, url_for)
from . import db, mailer, security
bp = Blueprint("auth", __name__)
CODE_TTL = 600
CODE_ATTEMPTS = 5
FAIL_WINDOW = 900
FAIL_LIMIT = 10
def conf():
return current_app.config["PB_CONF"]
def lookup_user(username):
"""Returns dict(username, email, pwhash, sysop) or None."""
c = conf()
if username == c.get("sysopuser"):
return {"username": username, "email": c.get("sysopemail"), "pwhash": c.get("sysoppassword"),
"sysop": True}
row = db.get().execute("SELECT username, email, pwhash FROM users WHERE username = ?", (username,)).fetchone()
if row:
return {"username": row["username"], "email": row["email"], "pwhash": row["pwhash"], "sysop": False}
return None
def _too_many_failures(addr):
cutoff = db.now() - FAIL_WINDOW
conn = db.get()
conn.execute("DELETE FROM login_failures WHERE ts < ?", (cutoff,))
n = conn.execute("SELECT COUNT(*) FROM login_failures WHERE addr = ?", (addr,)).fetchone()[0]
return n >= FAIL_LIMIT
def _record_failure(addr):
db.get().execute("INSERT INTO login_failures (addr, ts) VALUES (?, ?)", (addr, db.now()))
def csrf_token():
if "csrf" not in session:
session["csrf"] = secrets.token_urlsafe(32)
return session["csrf"]
@bp.before_app_request
def check_csrf():
if request.method in ("GET", "HEAD", "OPTIONS"):
return
sent = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token", "")
if not sent or not hmac.compare_digest(sent, session.get("csrf", "")):
abort(400, "invalid CSRF token")
@bp.before_app_request
def load_user():
g.user = None
name = session.get("user")
if not name:
return
if time.time() - session.get("login_at", 0) > conf().getint("sessionhours", 12) * 3600:
session.clear()
return
user = lookup_user(name)
# Password changes invalidate other sessions of that user.
if user and session.get("pwtag") == user["pwhash"][-16:]:
g.user = user
else:
session.clear()
def login_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if g.user is None:
if request.path.startswith("/api/"):
abort(401)
return redirect(url_for("auth.login"))
return view(*a, **kw)
return wrapped
def sysop_required(view):
@functools.wraps(view)
@login_required
def wrapped(*a, **kw):
if not g.user["sysop"]:
abort(403)
return view(*a, **kw)
return wrapped
@bp.route("/login", methods=["GET", "POST"])
def login():
if g.user:
return redirect(url_for("views.patch"))
if request.method == "GET":
return render_template("login.html")
addr = request.remote_addr or "?"
if _too_many_failures(addr):
flash("Too many failed attempts. Try again later.", "error")
return render_template("login.html"), 429
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
user = lookup_user(username)
if not security.verify_password(password, user["pwhash"] if user else None) or not user:
_record_failure(addr)
flash("Invalid username or password.", "error")
return render_template("login.html"), 401
if not user["email"]:
flash("This account has no email address for the login code.", "error")
return render_template("login.html"), 400
tok, code = security.token(), security.new_code()
conn = db.get()
conn.execute("DELETE FROM login_codes WHERE expires < ? OR username = ?", (db.now(), username))
conn.execute("INSERT INTO login_codes (token, username, codehash, expires) VALUES (?, ?, ?, ?)",
(tok, username, security.code_hash(tok, code), db.now() + CODE_TTL))
try:
mailer.send(conf(), user["email"], "PatchBay login code",
f"Your PatchBay login code is: {code}\n\n"
f"It is valid for {CODE_TTL // 60} minutes. If you did not try to log in, "
f"someone knows your password; change it.\n")
except mailer.MailError as e:
current_app.logger.error("sending login code failed: %s", e)
flash("Could not send the login code. Check the mail settings.", "error")
return render_template("login.html"), 503
session.clear()
session["pending"] = tok
return redirect(url_for("auth.verify"))
@bp.route("/verify", methods=["GET", "POST"])
def verify():
tok = session.get("pending")
if not tok:
return redirect(url_for("auth.login"))
if request.method == "GET":
return render_template("verify.html")
conn = db.get()
row = conn.execute("SELECT username, codehash, expires, attempts FROM login_codes WHERE token = ?",
(tok,)).fetchone()
if not row or row["expires"] < db.now() or row["attempts"] >= CODE_ATTEMPTS:
conn.execute("DELETE FROM login_codes WHERE token = ?", (tok,))
session.clear()
flash("The login code expired. Please log in again.", "error")
return redirect(url_for("auth.login"))
code = request.form.get("code", "").strip()
if not hmac.compare_digest(security.code_hash(tok, code), row["codehash"]):
conn.execute("UPDATE login_codes SET attempts = attempts + 1 WHERE token = ?", (tok,))
_record_failure(request.remote_addr or "?")
flash("Wrong code.", "error")
return render_template("verify.html"), 401
conn.execute("DELETE FROM login_codes WHERE token = ?", (tok,))
user = lookup_user(row["username"])
if not user:
session.clear()
return redirect(url_for("auth.login"))
session.clear()
session.permanent = True
session["user"] = user["username"]
session["login_at"] = time.time()
session["pwtag"] = user["pwhash"][-16:]
csrf_token()
return redirect(url_for("views.patch"))
@bp.route("/logout", methods=["POST"])
def logout():
session.clear()
return redirect(url_for("auth.login"))
def refresh_pwtag():
"""Keeps the current session valid after the user changed their own password."""
user = lookup_user(session["user"])
session["pwtag"] = user["pwhash"][-16:]

119
web/patchbay_web/conf.py Normal file
View File

@@ -0,0 +1,119 @@
"""Reading and updating patchbay.conf (shared with patchbayd, see config.c)."""
import os
import tempfile
DEFAULTS = {
"role": "client",
"database": "/etc/patchbay/patchbay.db",
"rundir": "/run/patchbay",
"webbind": "0.0.0.0",
"webport": "8443",
"tlscert": "/etc/patchbay/tls.crt",
"tlskey": "/etc/patchbay/tls.key",
"sessionhours": "12",
"sysopuser": "admin",
"sysopemail": "",
"sysoppassword": "",
"mailhost": "",
"mailport": "587",
"mailsecurity": "starttls",
"mailuser": "",
"mailpassword": "",
"mailfrom": "",
}
# Canonical spelling used when a key has to be appended to the file.
CANONICAL = {
"sysoppassword": "SysopPassword",
"sysopemail": "SysopEmail",
"mailhost": "MailHost",
"mailport": "MailPort",
"mailsecurity": "MailSecurity",
"mailuser": "MailUser",
"mailpassword": "MailPassword",
"mailfrom": "MailFrom",
}
def _parse_line(line):
s = line.strip()
if not s or s[0] in "#;[" or "=" not in s:
return None
key, val = s.split("=", 1)
val = val.strip()
if len(val) >= 2 and val[0] == '"' and val[-1] == '"':
val = val[1:-1]
return key.strip().lower(), val
class Config:
"""Keys are case-insensitive and stored lower-case."""
def __init__(self, path):
self.path = path
self.values = dict(DEFAULTS)
with open(path, encoding="utf-8") as f:
for line in f:
kv = _parse_line(line)
if kv:
self.values[kv[0]] = kv[1]
def get(self, key, default=None):
return self.values.get(key.lower(), default)
def getint(self, key, default=0):
try:
return int(self.get(key, default))
except (TypeError, ValueError):
return default
def update(self, changes):
"""Rewrites the given keys in place, keeping comments and order."""
changes = {k.lower(): v for k, v in changes.items()}
for v in changes.values():
if "\n" in str(v) or "\r" in str(v):
raise ValueError("newline in config value")
with open(self.path, encoding="utf-8") as f:
lines = f.readlines()
done = set()
out = []
for line in lines:
kv = _parse_line(line)
if kv and kv[0] in changes:
if kv[0] in done:
continue # drop duplicates of a rewritten key
key_text = line.split("=", 1)[0].strip()
out.append(f"{key_text} = {self._quote(changes[kv[0]])}\n")
done.add(kv[0])
else:
out.append(line)
missing = [k for k in changes if k not in done]
if missing:
if out and not out[-1].endswith("\n"):
out[-1] += "\n"
for k in missing:
out.append(f"{CANONICAL.get(k, k)} = {self._quote(changes[k])}\n")
st = os.stat(self.path)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path) or ".", prefix=".patchbay.conf.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.writelines(out)
os.chmod(tmp, st.st_mode & 0o777)
try:
os.chown(tmp, st.st_uid, st.st_gid)
except PermissionError:
pass
os.replace(tmp, self.path)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
self.values.update({k: str(v) for k, v in changes.items()})
@staticmethod
def _quote(v):
v = str(v)
return f'"{v}"' if v != v.strip() else v

View File

@@ -0,0 +1,29 @@
"""Client for the patchbayd API socket (one request line, one JSON line back)."""
import json
import os
import socket
from flask import current_app
def request(cmd, timeout=3.0):
path = os.path.join(current_app.config["PB_CONF"].get("rundir"), "api.sock")
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
s.connect(path)
s.sendall(cmd.encode() + b"\n")
data = b""
while not data.endswith(b"\n"):
chunk = s.recv(65536)
if not chunk:
break
data += chunk
return json.loads(data or b"{}")
except (OSError, ValueError) as e:
return {"ok": False, "error": f"daemon not reachable: {e}"}
def reload():
return request("RELOAD")

67
web/patchbay_web/db.py Normal file
View File

@@ -0,0 +1,67 @@
"""SQLite access. The schema is shared with patchbayd (schema.sql)."""
import os
import sqlite3
import time
from flask import current_app, g
_HERE = os.path.dirname(os.path.abspath(__file__))
# Installed copy next to the package, else the repository root.
SCHEMA_PATHS = [os.path.join(_HERE, "schema.sql"), os.path.join(_HERE, "..", "..", "schema.sql")]
def schema_sql():
for p in SCHEMA_PATHS:
if os.path.exists(p):
with open(p, encoding="utf-8") as f:
return f.read()
raise FileNotFoundError("schema.sql not found")
def connect(path):
old = os.umask(0o077) # the DB holds password hashes
try:
conn = sqlite3.connect(path, timeout=5, isolation_level=None)
finally:
os.umask(old)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 5000")
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init(path):
conn = connect(path)
conn.executescript(schema_sql())
# Databases from before tunnel nodes lack nodes.iface (same migration 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 ''")
conn.close()
def get():
if "db" not in g:
g.db = connect(current_app.config["PB_DB"])
return g.db
def close(_exc=None):
conn = g.pop("db", None)
if conn is not None:
conn.close()
def setting(key, default=None):
row = get().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else default
def set_setting(key, value):
get().execute("INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT (key) DO UPDATE SET value = excluded.value", (key, str(value)))
def now():
return int(time.time())

View File

@@ -0,0 +1,43 @@
"""Outgoing mail for 2FA codes, using the Mail* settings in patchbay.conf."""
import smtplib
import ssl
import sys
from email.message import EmailMessage
class MailError(Exception):
pass
def send(conf, to, subject, body):
host = conf.get("mailhost")
if not host:
raise MailError("mail server is not configured")
if host == "log":
# Development only: MailHost = log prints mails to the server log.
print(f"[mail to {to}] {subject}\n{body}", file=sys.stderr, flush=True)
return
msg = EmailMessage()
msg["From"] = conf.get("mailfrom") or conf.get("mailuser")
msg["To"] = to
msg["Subject"] = subject
msg.set_content(body)
port = conf.getint("mailport", 587)
security = (conf.get("mailsecurity") or "starttls").lower()
ctx = ssl.create_default_context()
try:
if security == "ssl":
server = smtplib.SMTP_SSL(host, port, context=ctx, timeout=20)
else:
server = smtplib.SMTP(host, port, timeout=20)
with server:
if security == "starttls":
server.starttls(context=ctx)
if conf.get("mailuser"):
server.login(conf.get("mailuser"), conf.get("mailpassword"))
server.send_message(msg)
except (OSError, smtplib.SMTPException) as e:
raise MailError(str(e)) from e

View File

@@ -0,0 +1,61 @@
"""Password hashing (scrypt from the standard library) and token helpers."""
import base64
import hashlib
import hmac
import secrets
PREFIX = "$scrypt$"
N, R, P = 2 ** 15, 8, 1
def _b64(b):
return base64.b64encode(b).decode().rstrip("=")
def _unb64(s):
return base64.b64decode(s + "=" * (-len(s) % 4))
def hash_password(password):
salt = secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode(), salt=salt, n=N, r=R, p=P, maxmem=64 * 1024 * 1024, dklen=32)
return f"{PREFIX}n={N},r={R},p={P}${_b64(salt)}${_b64(dk)}"
def is_hashed(value):
return value.startswith(PREFIX)
# Used for unknown users so a login attempt costs the same either way.
_DUMMY = None
def verify_password(password, stored):
global _DUMMY
if not stored or not is_hashed(stored):
if _DUMMY is None:
_DUMMY = hash_password("dummy")
stored, ok = _DUMMY, False
else:
ok = True
try:
params, salt, dk = stored[len(PREFIX):].split("$")
p = dict(kv.split("=") for kv in params.split(","))
calc = hashlib.scrypt(password.encode(), salt=_unb64(salt), n=int(p["n"]), r=int(p["r"]),
p=int(p["p"]), maxmem=64 * 1024 * 1024, dklen=len(_unb64(dk)))
except (ValueError, KeyError):
return False
return hmac.compare_digest(calc, _unb64(dk)) and ok
def token():
return secrets.token_urlsafe(32)
def code_hash(tok, code):
return hashlib.sha256(f"{tok}:{code}".encode()).hexdigest()
def new_code():
return f"{secrets.randbelow(10 ** 6):06d}"

View File

@@ -0,0 +1,71 @@
/* Shared helpers: CSRF-aware fetch, confirm dialogs, formatting. */
"use strict";
const PB = (() => {
const csrf = document.querySelector('meta[name="csrf-token"]').content;
async function api(method, url, body) {
const opts = { method, headers: { "X-CSRF-Token": csrf }, credentials: "same-origin" };
if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
if (res.status === 401) {
location.href = "/login";
throw new Error("not logged in");
}
let data = {};
try { data = await res.json(); } catch (e) { /* non-JSON error page */ }
if (!res.ok) throw new Error(data.error || res.statusText);
return data;
}
function bytes(n) {
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let i = 0;
n = Number(n) || 0;
while (Math.abs(n) >= 1000 && i < units.length - 1) { n /= 1000; i++; }
return (i === 0 ? n.toFixed(0) : n.toFixed(n < 10 ? 2 : n < 100 ? 1 : 0)) + " " + units[i];
}
function rate(bps) {
return bytes(bps) + "/s";
}
function ago(ts) {
if (!ts) return "never";
const s = Math.max(0, Date.now() / 1000 - ts);
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + " min ago";
if (s < 86400) return Math.floor(s / 3600) + " h ago";
return new Date(ts * 1000).toLocaleString();
}
function el(tag, attrs, ...children) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (k === "class") e.className = v;
else if (k === "text") e.textContent = v;
else e.setAttribute(k, v);
}
for (const c of children) e.append(c);
return e;
}
// Inline handlers are blocked by the CSP, so confirmations are wired here.
document.addEventListener("submit", (ev) => {
const msg = ev.target.dataset && ev.target.dataset.confirm;
if (msg && !window.confirm(msg)) ev.preventDefault();
});
function fillTimestamps() {
document.querySelectorAll("[data-ts]").forEach((n) => {
const t = n.querySelector(".ts");
if (t) t.textContent = ago(Number(n.dataset.ts));
});
}
document.addEventListener("DOMContentLoaded", fillTimestamps);
return { api, bytes, rate, ago, el, csrf };
})();

View File

@@ -0,0 +1,634 @@
/* Patch editor: node graph with draggable wires, saved to /api/graph. */
"use strict";
(() => {
const NODE_W = 220;
const SOCKET_Y = 47; // socket centre below the node's top edge
const TYPES = {
client_source: { title: "Client Source", input: false, output: true },
client_sink: { title: "Client Sink", input: true, output: false },
public_sink: { title: "Public Sink", input: true, output: false },
splitter: { title: "Splitter", input: true, output: true },
tunnel_source: { title: "Tunnel Source", input: false, output: true },
tunnel_sink: { title: "Tunnel Sink", input: true, output: false },
};
const SOURCE_TYPES = new Set(["client_source", "tunnel_source"]);
const TUNNEL_TYPES = new Set(["tunnel_source", "tunnel_sink"]);
const editor = document.getElementById("editor");
const canvas = document.getElementById("canvas");
const wireLayer = document.getElementById("wire-layer");
const saveState = document.getElementById("save-state");
const SVG = "http://www.w3.org/2000/svg";
const S = {
nodes: new Map(), // id -> node
els: new Map(), // id -> element
links: [], // {from, to}
clients: [],
interfaces: {}, // host id ("0" = target) -> [{name, addr}]
status: { clients: {}, sinks: {} },
live: {},
daemon: true,
pan: { x: 40, y: 40 },
zoom: 1,
sel: null, // {kind: "node", id} | {kind: "link", link}
nextTemp: -1,
dirty: false,
saving: false,
saveTimer: null,
addOffset: 0,
lastWire: null,
};
/* Graph helpers */
function inputLink(id) {
return S.links.find((l) => l.to === id);
}
// Follows splitters upstream to the client source feeding a node.
function sourceOf(id) {
let cur = id;
for (let i = 0; i < 64; i++) {
const n = S.nodes.get(cur);
if (!n) return null;
if (SOURCE_TYPES.has(n.type)) return n;
const l = inputLink(cur);
if (!l) return null;
cur = l.from;
}
return null;
}
function createsLoop(from, to) {
// Walking upstream from "from" must not reach "to".
let cur = from;
for (let i = 0; i < 64; i++) {
if (cur === to) return true;
const l = inputLink(cur);
if (!l) return false;
cur = l.from;
}
return true;
}
/* View transform */
function applyView() {
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})`);
}
function toCanvas(clientX, clientY) {
const r = editor.getBoundingClientRect();
return { x: (clientX - r.left - S.pan.x) / S.zoom, y: (clientY - r.top - S.pan.y) / S.zoom };
}
function fit() {
if (!S.nodes.size) return;
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of S.nodes.values()) {
const el = S.els.get(n.id);
x0 = Math.min(x0, n.x);
y0 = Math.min(y0, n.y);
x1 = Math.max(x1, n.x + NODE_W);
y1 = Math.max(y1, n.y + (el ? el.offsetHeight : 150));
}
const r = editor.getBoundingClientRect();
const pad = 60;
S.zoom = Math.max(0.3, Math.min(1.2, Math.min((r.width - pad * 2) / (x1 - x0), (r.height - pad * 2) / (y1 - y0))));
S.pan.x = (r.width - (x1 - x0) * S.zoom) / 2 - x0 * S.zoom;
S.pan.y = (r.height - (y1 - y0) * S.zoom) / 2 - y0 * S.zoom;
applyView();
}
/* Wires */
function wirePath(x1, y1, x2, y2) {
const dx = Math.max(40, Math.abs(x2 - x1) * 0.5);
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
function outPos(n) { return { x: n.x + NODE_W + 1, y: n.y + SOCKET_Y }; }
function inPos(n) { return { x: n.x - 1, y: n.y + SOCKET_Y }; }
function drawWires() {
wireLayer.replaceChildren();
for (const l of S.links) {
const a = S.nodes.get(l.from), b = S.nodes.get(l.to);
if (!a || !b) continue;
const p = outPos(a), q = inPos(b);
const d = wirePath(p.x, p.y, q.x, q.y);
const src = sourceOf(l.from);
let cls = src ? src.proto : "none";
if (src && b.type !== "splitter" && b.proto !== src.proto) cls += " bad";
if (S.sel && S.sel.kind === "link" && S.sel.link === l) cls += " selected";
const vis = document.createElementNS(SVG, "path");
vis.setAttribute("d", d);
vis.setAttribute("class", cls);
const hit = document.createElementNS(SVG, "path");
hit.setAttribute("d", d);
hit.setAttribute("class", "hit");
hit.addEventListener("mousedown", (ev) => {
ev.stopPropagation();
// Selecting re-renders the wires, so dblclick would never fire; time it instead.
const now = Date.now();
if (S.lastWire && S.lastWire.link === l && now - S.lastWire.t < 400) {
S.lastWire = null;
removeLink(l);
return;
}
S.lastWire = { link: l, t: now };
select({ kind: "link", link: l });
editor.focus();
});
wireLayer.append(vis, hit);
// A selected wire gets a delete button at its midpoint (bezier t = 0.5).
if (S.sel && S.sel.kind === "link" && S.sel.link === l) {
const dx = Math.max(40, Math.abs(q.x - p.x) * 0.5);
const mx = (p.x + 3 * (p.x + dx) + 3 * (q.x - dx) + q.x) / 8;
const my = (p.y + q.y) / 2;
const btn = document.createElementNS(SVG, "g");
btn.setAttribute("class", "wire-del");
btn.setAttribute("transform", `translate(${mx} ${my})`);
const title = document.createElementNS(SVG, "title");
title.textContent = "Remove connection";
const circle = document.createElementNS(SVG, "circle");
circle.setAttribute("r", "10");
const x = document.createElementNS(SVG, "path");
x.setAttribute("d", "M -4 -4 L 4 4 M 4 -4 L -4 4");
btn.append(title, circle, x);
btn.addEventListener("mousedown", (ev) => ev.stopPropagation());
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
removeLink(l);
});
wireLayer.append(btn);
}
}
}
function removeLink(l) {
S.links = S.links.filter((x) => x !== l);
if (S.sel && S.sel.kind === "link" && S.sel.link === l) S.sel = null;
changed();
}
/* Nodes */
function field(label, input) {
return PB.el("label", {}, PB.el("span", { text: label }), input);
}
// Tunnel nodes may live on the target itself (client_id null).
function clientSelect(n, onChange) {
const sel = PB.el("select", { "aria-label": "Host" });
sel.append(PB.el("option", { value: "", text: TUNNEL_TYPES.has(n.type) ? "Target (this server)" : "- choose client -" }));
for (const c of S.clients) {
const o = PB.el("option", { value: String(c.id), text: c.name });
if (n.client_id === c.id) o.selected = true;
sel.append(o);
}
sel.addEventListener("change", () => {
n.client_id = sel.value ? Number(sel.value) : null;
if (onChange) onChange();
changed();
});
return sel;
}
// Free text with suggestions from the tun interfaces the host reported.
function ifaceInput(n) {
const listId = `ifaces-${Math.random().toString(36).slice(2)}`;
const list = PB.el("datalist", { id: listId });
const inp = PB.el("input", { value: n.iface || "", placeholder: "tun0", list: listId, spellcheck: "false" });
inp.addEventListener("input", () => { n.iface = inp.value.trim(); changed(); });
const fill = () => {
list.replaceChildren();
for (const i of S.interfaces[String(n.client_id || 0)] || []) {
list.append(PB.el("option", { value: i.name, text: i.addr !== "-" ? `${i.name} (${i.addr})` : i.name }));
}
};
fill();
const wrap = PB.el("span", { class: "iface-wrap" }, inp, list);
wrap._fill = fill;
return wrap;
}
function textInput(n, key, placeholder) {
const inp = PB.el("input", { value: n[key] || "", placeholder: placeholder || "", spellcheck: "false" });
inp.addEventListener("input", () => { n[key] = inp.value.trim(); changed(); });
return inp;
}
function portInput(n) {
const inp = PB.el("input", { type: "number", min: "1", max: "65535", value: n.port ? String(n.port) : "", placeholder: "port" });
inp.addEventListener("input", () => { n.port = Number(inp.value) || 0; changed(); });
return inp;
}
function protoSelect(n) {
const sel = PB.el("select", { "aria-label": "Protocol" });
for (const p of ["tcp", "udp"]) {
const o = PB.el("option", { value: p, text: p.toUpperCase() });
if (n.proto === p) o.selected = true;
sel.append(o);
}
sel.addEventListener("change", () => { n.proto = sel.value; changed(); });
return sel;
}
function buildNode(n) {
const t = TYPES[n.type];
const el = PB.el("div", { class: `node ${n.type}` });
const title = PB.el("span", { class: "title", text: t.title });
const close = PB.el("button", { class: "x", type: "button", title: "Delete node", "aria-label": "Delete node", text: "\u00d7" });
const head = PB.el("div", { class: "node-head" }, title, close);
const body = PB.el("div", { class: "node-body" });
if (n.type === "client_source") {
body.append(field("Client", clientSelect(n)), field("Address", textInput(n, "host", "127.0.0.1")),
field("Port", portInput(n)), field("Proto", protoSelect(n)));
} else if (n.type === "client_sink") {
body.append(field("Client", clientSelect(n)), field("Bind", textInput(n, "host", "127.0.0.1")),
field("Port", portInput(n)), field("Proto", protoSelect(n)));
} else if (n.type === "public_sink") {
body.append(field("Bind", textInput(n, "host", "0.0.0.0")), field("Port", portInput(n)), field("Proto", protoSelect(n)));
} else if (TUNNEL_TYPES.has(n.type)) {
const iface = ifaceInput(n);
body.append(field("Host", clientSelect(n, iface._fill)), field("Iface", iface));
if (n.type === "tunnel_source") body.append(field("Peer", textInput(n, "host", "e.g. 10.100.0.2")));
body.append(field("Port", portInput(n)), field("Proto", protoSelect(n)));
}
body.append(field("Label", textInput(n, "label", "optional")));
const status = PB.el("div", { class: "node-status" });
body.append(status);
el.append(head, body);
if (t.input) el.append(PB.el("div", { class: "socket in", "data-node": String(n.id), title: "Input" }));
if (t.output) el.append(PB.el("div", { class: "socket out", "data-node": String(n.id), title: "Output" }));
close.addEventListener("mousedown", (ev) => ev.stopPropagation());
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;
ev.stopPropagation();
select({ kind: "node", id: n.id });
});
el._status = status;
el._title = title;
return el;
}
function placeNode(n) {
const el = S.els.get(n.id);
el.style.left = n.x + "px";
el.style.top = n.y + "px";
}
function renderAll() {
canvas.replaceChildren();
S.els.clear();
for (const n of S.nodes.values()) {
const el = buildNode(n);
S.els.set(n.id, el);
canvas.append(el);
placeNode(n);
}
// Socket data attributes carry ids; refresh after remapping.
updateStatus();
drawWires();
}
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 n = {
id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "",
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),
};
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 });
changed();
}
function removeNode(id) {
S.nodes.delete(id);
const el = S.els.get(id);
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;
drawWires();
changed();
}
function select(sel) {
S.sel = sel;
for (const [id, el] of S.els) el.classList.toggle("selected", !!sel && sel.kind === "node" && sel.id === id);
drawWires();
}
/* Status */
function updateStatus() {
for (const n of S.nodes.values()) {
const el = S.els.get(n.id);
if (!el) continue;
el._title.textContent = TYPES[n.type].title + (n.id > 0 ? ` #${n.id}` : "");
const st = el._status;
st.replaceChildren();
st.classList.remove("err");
const parts = [];
if (n.client_id && n.type !== "splitter" && n.type !== "public_sink") {
const on = !!S.status.clients[String(n.client_id)];
parts.push(PB.el("span", { class: `dot ${on ? "on" : "off"}` }), PB.el("span", { text: on ? "online" : "offline" }));
}
if (n.type === "splitter") {
const outs = S.links.filter((l) => l.from === n.id).length;
parts.push(PB.el("span", { text: `fans out to ${outs} sink${outs === 1 ? "" : "s"}` }));
}
if (TYPES[n.type].input && n.type !== "splitter" && n.id > 0 && !S.dirty) {
const s = S.status.sinks[String(n.id)];
const lv = S.live[String(n.id)];
if (s && !s.ok) {
st.classList.add("err");
parts.push(PB.el("span", { text: s.error }));
} else if (s) {
const txt = lv ? `in ${PB.rate(lv.in)}, out ${PB.rate(lv.out)}, ${lv.active} open` : "ready";
parts.push(PB.el("span", { text: s.source_online ? txt : "source offline" }));
}
}
if (!S.daemon && n.type !== "splitter") {
st.classList.add("err");
parts.length = 0;
parts.push(PB.el("span", { text: "daemon not reachable" }));
}
st.append(...parts);
}
}
async function pollStatus() {
try {
const [st, lv] = await Promise.all([PB.api("GET", "/api/status"), PB.api("GET", "/api/live")]);
S.daemon = st.daemon;
S.status = { clients: st.clients || {}, sinks: st.sinks || {} };
S.live = lv.sinks || {};
} catch (e) {
S.daemon = false;
}
updateStatus();
}
/* Saving */
function setSaveState(text, err) {
saveState.textContent = text;
saveState.classList.toggle("err", !!err);
}
function changed() {
S.dirty = true;
setSaveState("Unsaved changes");
clearTimeout(S.saveTimer);
S.saveTimer = setTimeout(save, 700);
drawWires();
updateStatus();
}
async function save() {
if (S.saving) {
S.saveTimer = setTimeout(save, 300);
return;
}
S.saving = true;
S.dirty = false;
setSaveState("Saving...");
const payload = {
nodes: [...S.nodes.values()].map((n) => ({ ...n })),
links: S.links.map((l) => ({ from: l.from, to: l.to })),
};
try {
const res = await PB.api("PUT", "/api/graph", payload);
remapIds(res.ids || {});
setSaveState(res.daemon ? "Saved" : "Saved (daemon offline)", !res.daemon);
await pollStatus();
} catch (e) {
S.dirty = true;
setSaveState("Not saved: " + e.message, true);
} finally {
S.saving = false;
}
}
function remapIds(ids) {
let any = false;
for (const [oldStr, newId] of Object.entries(ids)) {
const oldId = Number(oldStr);
if (oldId === newId || !S.nodes.has(oldId)) continue;
const n = S.nodes.get(oldId);
S.nodes.delete(oldId);
n.id = newId;
S.nodes.set(newId, n);
for (const l of S.links) {
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;
any = true;
}
if (any) renderAll();
}
/* Mouse interaction */
function startNodeDrag(ev, n) {
if (ev.button !== 0 || ev.target.closest("button")) return;
ev.preventDefault();
ev.stopPropagation();
select({ kind: "node", id: n.id });
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);
moved = true;
placeNode(n);
drawWires();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
if (moved) changed();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
function startWireDrag(ev, fromId) {
ev.preventDefault();
ev.stopPropagation();
const from = S.nodes.get(fromId);
const temp = document.createElementNS(SVG, "path");
temp.setAttribute("class", "temp");
wireLayer.append(temp);
let target = null;
const move = (e) => {
const p = outPos(from), q = toCanvas(e.clientX, e.clientY);
temp.setAttribute("d", wirePath(p.x, p.y, q.x, q.y));
const hit = document.elementFromPoint(e.clientX, e.clientY);
const sock = hit && hit.closest(".socket.in");
if (target && target !== sock) target.classList.remove("target");
target = sock && Number(sock.dataset.node) !== fromId ? sock : null;
if (target) target.classList.add("target");
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
temp.remove();
if (target) {
target.classList.remove("target");
connect(fromId, Number(target.dataset.node));
}
drawWires();
};
move(ev);
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
function connect(from, to) {
if (from === to || createsLoop(from, to)) {
setSaveState("That link would create a loop", true);
return;
}
S.links = S.links.filter((l) => l.to !== to);
// A source has a single output; fan-out goes through a splitter.
if (SOURCE_TYPES.has(S.nodes.get(from).type)) S.links = S.links.filter((l) => l.from !== from);
S.links.push({ from, to });
changed();
}
canvas.addEventListener("mousedown", (ev) => {
const sock = ev.target.closest(".socket");
if (!sock || ev.button !== 0) return;
const id = Number(sock.dataset.node);
if (sock.classList.contains("out")) {
startWireDrag(ev, id);
} else {
// Grabbing a connected input detaches the wire and keeps dragging it.
const l = inputLink(id);
if (l) {
removeLink(l);
startWireDrag(ev, l.from);
} else {
ev.stopPropagation();
}
}
});
editor.addEventListener("mousedown", (ev) => {
if (ev.button !== 0 && ev.button !== 1) return;
if (ev.target.closest(".node")) return;
ev.preventDefault();
editor.focus();
select(null);
const sx = ev.clientX, sy = ev.clientY, px = S.pan.x, py = S.pan.y;
editor.classList.add("panning");
const move = (e) => {
S.pan.x = px + e.clientX - sx;
S.pan.y = py + e.clientY - sy;
applyView();
};
const up = () => {
editor.classList.remove("panning");
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
});
editor.addEventListener("wheel", (ev) => {
if (ev.target.closest("select")) return;
ev.preventDefault();
const r = editor.getBoundingClientRect();
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
const z = Math.max(0.3, Math.min(2, S.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1)));
S.pan.x = mx - (mx - S.pan.x) * (z / S.zoom);
S.pan.y = my - (my - S.pan.y) * (z / S.zoom);
S.zoom = z;
applyView();
}, { passive: false });
editor.addEventListener("keydown", (ev) => {
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);
else removeLink(S.sel.link);
}
});
/* Toolbar */
const addMenu = document.getElementById("add-menu");
const addBtn = document.getElementById("add-btn");
function setMenu(open) {
addMenu.classList.toggle("open", open);
addBtn.setAttribute("aria-expanded", String(open));
}
addBtn.addEventListener("click", () => setMenu(!addMenu.classList.contains("open")));
addMenu.querySelectorAll("[data-add]").forEach((b) => b.addEventListener("click", () => {
setMenu(false);
addNode(b.dataset.add);
}));
document.addEventListener("click", (ev) => { if (!addMenu.contains(ev.target)) setMenu(false); });
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") setMenu(false); });
document.getElementById("fit-btn").addEventListener("click", fit);
window.addEventListener("beforeunload", (ev) => {
if (S.dirty || S.saving) {
save();
ev.preventDefault();
}
});
/* Start */
async function load() {
try {
const g = await PB.api("GET", "/api/graph");
S.clients = g.clients;
S.interfaces = g.interfaces || {};
for (const n of g.nodes) S.nodes.set(n.id, n);
S.links = g.links;
renderAll();
applyView();
fit();
setSaveState(S.nodes.size ? "Saved" : "Empty patch: use Add to create nodes");
} catch (e) {
setSaveState("Loading failed: " + e.message, true);
}
await pollStatus();
setInterval(pollStatus, 3000);
}
load();
})();

View File

@@ -0,0 +1,15 @@
"use strict";
document.getElementById("refresh-services").addEventListener("click", async (ev) => {
const btn = ev.currentTarget;
btn.disabled = true;
btn.textContent = "Refreshing...";
try {
await PB.api("POST", "/api/services/refresh");
// Clients answer asynchronously over their control channel.
setTimeout(() => location.reload(), 1500);
} catch (e) {
btn.textContent = "Failed: " + e.message;
btn.disabled = false;
}
});

View File

@@ -0,0 +1,245 @@
/* Stats page: live tiles plus throughput and connection charts (plain SVG). */
"use strict";
(() => {
const sinkSel = document.getElementById("sink-select");
if (!sinkSel) return;
const rangeGroup = document.getElementById("range-group");
const tooltip = document.getElementById("chart-tooltip");
const rateBox = document.querySelector('#chart-rate .chart');
const connBox = document.querySelector('#chart-conns .chart');
const tbody = document.querySelector("#stats-table tbody");
const SVG = "http://www.w3.org/2000/svg";
const M = { top: 12, right: 44, bottom: 26, left: 64 };
let range = "24h";
let data = null;
function svgEl(tag, attrs) {
const e = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
return e;
}
function niceMax(v) {
if (v <= 0) return 1;
const p = Math.pow(10, Math.floor(Math.log10(v)));
for (const m of [1, 2, 2.5, 5, 10]) if (m * p >= v) return m * p;
return 10 * p;
}
function timeLabel(ts, span) {
const d = new Date(ts * 1000);
if (span <= 2 * 86400) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (span <= 60 * 86400) return d.toLocaleDateString([], { month: "short", day: "numeric" });
return d.toLocaleDateString([], { year: "2-digit", month: "short" });
}
function fullTime(ts) {
return new Date(ts * 1000).toLocaleString();
}
// Dense series with zero-filled gaps, one entry per bucket.
function buckets(d) {
const byTs = new Map(d.points.map((p) => [p[0], p]));
let start = d.since ? Math.floor(d.since / d.step) * d.step : (d.points.length ? d.points[0][0] : d.now);
const end = Math.floor(d.now / d.step) * d.step;
if (end - start > 2000 * d.step) start = end - 2000 * d.step;
const out = [];
for (let t = start; t <= end; t += d.step) {
const p = byTs.get(t);
out.push({ ts: t, in: p ? p[1] : 0, out: p ? p[2] : 0, conns: p ? p[3] : 0 });
}
return out;
}
function frame(box, yMax, fmt, rows) {
box.replaceChildren();
const w = box.clientWidth, h = box.clientHeight;
const svg = svgEl("svg", { viewBox: `0 0 ${w} ${h}`, role: "img" });
const pw = w - M.left - M.right, ph = h - M.top - M.bottom;
const x = (i) => M.left + (rows.length > 1 ? (i / (rows.length - 1)) * pw : pw / 2);
const y = (v) => M.top + ph - (v / yMax) * ph;
const axis = svgEl("g", { class: "axis" });
for (let k = 0; k <= 4; k++) {
const v = (yMax * k) / 4, yy = y(v);
axis.append(svgEl("line", { class: "gridline", x1: M.left, x2: M.left + pw, y1: yy, y2: yy }));
const t = svgEl("text", { x: M.left - 8, y: yy + 4, "text-anchor": "end" });
t.textContent = fmt(v);
axis.append(t);
}
const span = rows.length ? rows[rows.length - 1].ts - rows[0].ts : 0;
const nt = Math.max(2, Math.min(6, Math.floor(pw / 110)));
for (let k = 0; k <= nt && rows.length > 1; k++) {
const i = Math.round((k / nt) * (rows.length - 1));
const t = svgEl("text", { x: x(i), y: h - 6, "text-anchor": k === 0 ? "start" : k === nt ? "end" : "middle" });
t.textContent = timeLabel(rows[i].ts, span);
axis.append(t);
}
svg.append(axis);
box.append(svg);
return { svg, w, h, pw, ph, x, y };
}
function showTip(ev, rows) {
tooltip.replaceChildren();
for (const r of rows) {
if (r.time) {
tooltip.append(PB.el("div", { class: "t-time", text: r.time }));
continue;
}
const row = PB.el("div", { class: "t-row" });
if (r.cls) row.append(PB.el("span", { class: `swatch-line ${r.cls}` }));
row.append(PB.el("strong", { text: r.value }), PB.el("span", { class: "muted", text: r.label }));
tooltip.append(row);
}
tooltip.hidden = false;
const tw = tooltip.offsetWidth, th = tooltip.offsetHeight;
let left = ev.clientX + 14, top = ev.clientY - th - 10;
if (left + tw > window.innerWidth - 8) left = ev.clientX - tw - 14;
if (top < 8) top = ev.clientY + 16;
tooltip.style.left = left + "px";
tooltip.style.top = top + "px";
}
function hideTip() {
tooltip.hidden = true;
}
function drawRate(rows, step) {
const rates = rows.map((r) => ({ ts: r.ts, a: r.in / step, b: r.out / step }));
const yMax = niceMax(Math.max(0, ...rates.map((r) => Math.max(r.a, r.b))));
const f = frame(rateBox, yMax, (v) => PB.rate(v), rows);
if (!rows.length) return;
for (const [key, cls, label] of [["a", "s1", "In"], ["b", "s2", "Out"]]) {
const d = rates.map((r, i) => `${i ? "L" : "M"} ${f.x(i).toFixed(1)} ${f.y(r[key]).toFixed(1)}`).join(" ");
f.svg.append(svgEl("path", { class: `line ${cls}`, d }));
// Direct end label; the legend above repeats it.
const last = rates[rates.length - 1];
const t = svgEl("text", { class: "endlabel", x: f.x(rates.length - 1) + 6, y: f.y(last[key]) + 4 });
t.textContent = label;
f.svg.append(t);
}
// Keep end labels apart when both series end at the same height.
const labels = f.svg.querySelectorAll(".endlabel");
if (labels.length === 2 && Math.abs(labels[0].getAttribute("y") - labels[1].getAttribute("y")) < 12) {
labels[0].setAttribute("y", Number(labels[0].getAttribute("y")) - 6);
labels[1].setAttribute("y", Number(labels[1].getAttribute("y")) + 6);
}
const cross = svgEl("line", { class: "crosshair", y1: M.top, y2: M.top + f.ph, visibility: "hidden" });
const m1 = svgEl("circle", { class: "marker s1", r: 4, visibility: "hidden" });
const m2 = svgEl("circle", { class: "marker s2", r: 4, visibility: "hidden" });
const hit = svgEl("rect", { x: M.left, y: M.top, width: f.pw, height: f.ph, fill: "transparent" });
f.svg.append(cross, m1, m2, hit);
hit.addEventListener("pointermove", (ev) => {
const r = f.svg.getBoundingClientRect();
const px = ((ev.clientX - r.left) / r.width) * f.w;
const i = Math.max(0, Math.min(rates.length - 1, Math.round(((px - M.left) / f.pw) * (rates.length - 1))));
const xx = f.x(i);
for (const [el, v] of [[cross, null], [m1, rates[i].a], [m2, rates[i].b]]) {
el.setAttribute("visibility", "visible");
if (v === null) { el.setAttribute("x1", xx); el.setAttribute("x2", xx); }
else { el.setAttribute("cx", xx); el.setAttribute("cy", f.y(v)); }
}
showTip(ev, [
{ time: fullTime(rates[i].ts) },
{ cls: "s1", value: PB.rate(rates[i].a), label: "In" },
{ cls: "s2", value: PB.rate(rates[i].b), label: "Out" },
]);
});
hit.addEventListener("pointerleave", () => {
[cross, m1, m2].forEach((e) => e.setAttribute("visibility", "hidden"));
hideTip();
});
}
function drawConns(rows) {
const yMax = niceMax(Math.max(0, ...rows.map((r) => r.conns)));
const f = frame(connBox, yMax, (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1)), rows);
if (!rows.length) return;
const slot = f.pw / rows.length;
const bw = Math.max(1, slot - 2); // 2px surface gap between bars
rows.forEach((r, i) => {
const x0 = M.left + i * slot + (slot - bw) / 2;
// The hit area spans the whole slot and plot height, not just the bar.
const hit = svgEl("rect", { x: M.left + i * slot, y: M.top, width: slot, height: f.ph, fill: "transparent" });
let bar = null;
if (r.conns > 0) {
const top = f.y(r.conns);
bar = svgEl("rect", { class: "bar", x: x0, y: top, width: bw, height: M.top + f.ph - top, rx: bw >= 8 ? 4 : 0 });
f.svg.append(bar);
}
hit.addEventListener("pointermove", (ev) => {
if (bar) bar.classList.add("hover");
showTip(ev, [{ time: fullTime(r.ts) }, { value: String(r.conns), label: r.conns === 1 ? "connection" : "connections" }]);
});
hit.addEventListener("pointerleave", () => {
if (bar) bar.classList.remove("hover");
hideTip();
});
f.svg.append(hit);
});
}
function fillTable(d) {
tbody.replaceChildren();
for (const p of [...d.points].reverse()) {
tbody.append(PB.el("tr", {},
PB.el("td", { text: fullTime(p[0]) }),
PB.el("td", { class: "num", text: PB.bytes(p[1]) }),
PB.el("td", { class: "num", text: PB.bytes(p[2]) }),
PB.el("td", { class: "num", text: String(p[3]) })));
}
if (!d.points.length) tbody.append(PB.el("tr", {}, PB.el("td", { colspan: "4", class: "muted", text: "No traffic recorded in this range." })));
}
function render() {
if (!data) return;
const rows = buckets(data);
drawRate(rows, data.step);
drawConns(rows);
const total = data.points.reduce((s, p) => s + p[1] + p[2], 0);
document.getElementById("t-total").textContent = PB.bytes(total);
}
async function loadHistory() {
rateBox.classList.add("loading");
connBox.classList.add("loading");
try {
data = await PB.api("GET", `/api/stats?sink=${encodeURIComponent(sinkSel.value)}&range=${range}`);
render();
fillTable(data);
} catch (e) {
rateBox.replaceChildren(PB.el("p", { class: "muted", text: "Loading failed: " + e.message }));
} finally {
rateBox.classList.remove("loading");
connBox.classList.remove("loading");
}
}
async function loadLive() {
try {
const lv = await PB.api("GET", "/api/live");
const s = (lv.sinks || {})[sinkSel.value];
document.getElementById("t-in").textContent = lv.daemon ? PB.rate(s ? s.in : 0) : "offline";
document.getElementById("t-out").textContent = lv.daemon ? PB.rate(s ? s.out : 0) : "offline";
document.getElementById("t-active").textContent = lv.daemon ? String(s ? s.active : 0) : "-";
} catch (e) { /* keep last values */ }
}
sinkSel.addEventListener("change", () => { loadHistory(); loadLive(); });
rangeGroup.querySelectorAll("button").forEach((b) => b.addEventListener("click", () => {
rangeGroup.querySelectorAll("button").forEach((x) => x.classList.toggle("selected", x === b));
range = b.dataset.range;
loadHistory();
}));
window.addEventListener("resize", render);
loadHistory();
loadLive();
setInterval(loadLive, 2000);
setInterval(loadHistory, 60000);
})();

View File

@@ -0,0 +1,246 @@
/* PatchBay UI: dark, node-editor inspired theme. */
:root {
color-scheme: dark;
--bg: #1d1d1d;
--surface-1: #1a1a19;
--surface-2: #262626;
--surface-3: #303030;
--border: #3d3d3d;
--text-primary: #ececec;
--text-secondary: #c3c2b7;
--text-muted: #8f8e88;
--accent: #4a8fe0;
--danger: #d03b3b;
--good: #0ca30c;
--series-1: #3987e5; /* in / TCP */
--series-2: #d95926; /* out / UDP */
--grid: #2f2f2d;
--node-source: #2e7d4f;
--node-csink: #2f5f9e;
--node-psink: #a8452a;
--node-split: #5a5a5a;
--node-tsource: #1f7a7a;
--node-tsink: #6a4a9e;
--radius: 6px;
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text-primary);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--accent); }
code, .mono { font-family: var(--mono); font-size: 12.5px; }
h1 { font-size: 22px; margin: 0 0 16px; }
h2 { font-size: 16px; margin: 0 0 12px; display: flex; align-items: center; gap: 8px; }
h3 { font-size: 14px; margin: 18px 0 8px; }
.muted { color: var(--text-muted); }
.small { font-size: 12.5px; }
.right { text-align: right; }
/* Top bar */
.topbar {
display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
padding: 8px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; letter-spacing: .02em; }
.topbar nav { display: flex; gap: 4px; }
.topbar nav a {
color: var(--text-secondary); text-decoration: none; padding: 6px 12px; border-radius: var(--radius);
}
.topbar nav a:hover { background: var(--surface-3); }
.topbar nav a.active { background: var(--surface-3); color: var(--text-primary); }
.tools { display: flex; align-items: center; gap: 8px; }
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.who { color: var(--text-muted); }
/* Controls */
.btn {
font: inherit; color: var(--text-primary); background: var(--surface-3);
border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 14px; cursor: pointer;
}
.btn:hover { background: #3a3a3a; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { filter: brightness(1.1); }
.btn.danger { color: #ff8a8a; }
.btn.small { padding: 3px 10px; font-size: 12.5px; }
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%;
}
input:focus, select:focus, textarea:focus, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
label { display: flex; flex-direction: column; gap: 4px; color: var(--text-secondary); font-size: 13px; }
/* Layout */
.page { max-width: 1100px; margin: 0 auto; padding: 24px 16px 48px; }
.page-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.page-head h1 { margin: 0; }
.card {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; margin: 16px 0;
}
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; align-items: end; margin-top: 12px; }
.form-grid .wide { grid-column: 1 / -1; }
.form-grid .actions { grid-column: 1 / -1; }
.inline-form { display: flex; gap: 8px; align-items: center; flex: 1; flex-wrap: wrap; }
.inline-form input { width: auto; flex: 1; min-width: 160px; }
.user-row { display: flex; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
table.grid { width: 100%; border-collapse: collapse; }
table.grid th, table.grid td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
table.grid th { color: var(--text-muted); font-weight: 600; font-size: 12.5px; }
table.grid .num { text-align: right; font-variant-numeric: tabular-nums; }
td.key { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.table-wrap { max-height: 360px; overflow: auto; margin-top: 8px; }
.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; }
.dot.on { background: var(--good); }
.dot.off { background: #666; }
/* Flash messages */
.flashes { max-width: 1100px; margin: 12px auto 0; padding: 0 16px; }
.flash { padding: 8px 12px; border-radius: var(--radius); margin: 6px 0; border: 1px solid var(--border); }
.flash.ok { background: #1f3323; border-color: #2e5a36; }
.flash.error { background: #3a1f1f; border-color: #6a2e2e; }
/* Login */
body.auth main { display: flex; justify-content: center; padding-top: 12vh; }
.auth-card { width: 340px; display: flex; flex-direction: column; gap: 12px; }
.auth-card h1 { margin: 0; }
input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; text-align: center; }
/* Patch editor */
.editor-main { position: absolute; inset: 49px 0 0 0; }
#editor {
position: absolute; inset: 0; overflow: hidden; outline: none; cursor: default;
background-color: var(--surface-1);
background-image:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 24px 24px;
}
#editor.panning { cursor: grabbing; }
#wires { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
#wires path { fill: none; stroke-width: 2.5; pointer-events: stroke; cursor: pointer; }
#wires path.hit { stroke: transparent; stroke-width: 14; }
#wires path.tcp { stroke: var(--series-1); }
#wires path.udp { stroke: var(--series-2); }
#wires path.none { stroke: #9a9a9a; }
#wires path.selected { stroke: #fff; stroke-width: 3.5; }
#wires path.temp { stroke: #ddd; stroke-dasharray: 6 4; pointer-events: none; }
#wires path.bad { stroke-dasharray: 4 4; }
#wires .wire-del { pointer-events: all; cursor: pointer; }
#wires .wire-del circle { fill: var(--surface-3); stroke: #fff; stroke-width: 1.5; }
#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; }
.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;
}
.node {
position: absolute; width: 220px; background: var(--surface-3); border: 1px solid #111;
border-radius: 7px; box-shadow: 0 4px 14px rgba(0, 0, 0, .45); user-select: none;
}
.node.selected { outline: 2px solid #f0f0f0; }
.node-head {
display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 6px 6px 0 0;
font-weight: 600; font-size: 12.5px; cursor: grab;
}
.node.client_source .node-head { background: var(--node-source); }
.node.client_sink .node-head { background: var(--node-csink); }
.node.public_sink .node-head { background: var(--node-psink); }
.node.splitter .node-head { background: var(--node-split); }
.node.tunnel_source .node-head { background: var(--node-tsource); }
.node.tunnel_sink .node-head { background: var(--node-tsink); }
.iface-wrap { display: block; width: 100%; }
.node-head .title { flex: 1; }
.node-head .x {
background: none; border: 0; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; opacity: .7; padding: 0 2px;
}
.node-head .x:hover { opacity: 1; }
.node-body { padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 6px; }
.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-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 {
position: absolute; top: 40px; width: 14px; height: 14px; border-radius: 50%;
background: #c8c8c8; border: 2px solid #111; cursor: crosshair;
}
.socket.in { left: -8px; }
.socket.out { right: -8px; }
.socket:hover, .socket.target { background: #fff; transform: scale(1.25); }
.dropdown { position: relative; }
.dropdown .menu {
display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
background: var(--surface-3); border: 1px solid var(--border); border-radius: var(--radius); padding: 4px;
box-shadow: 0 6px 18px rgba(0, 0, 0, .5);
}
.dropdown.open .menu { display: block; }
.dropdown .menu button {
display: block; width: 100%; text-align: left; font: inherit; color: var(--text-primary);
background: none; border: 0; padding: 6px 10px; border-radius: 4px; cursor: pointer;
}
.dropdown .menu button:hover, .dropdown .menu button:focus { background: var(--accent); outline: none; }
.save-state { color: var(--text-muted); font-size: 12.5px; min-width: 120px; }
.save-state.err { color: #ff8a8a; }
/* Stats */
.filter-row { display: flex; gap: 16px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; }
.filter-row label { min-width: 280px; flex: 1; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.segmented button {
font: inherit; background: var(--surface-2); color: var(--text-secondary); border: 0; padding: 6px 12px; cursor: pointer;
border-right: 1px solid var(--border);
}
.segmented button:last-child { border-right: 0; }
.segmented button.selected { background: var(--surface-3); color: var(--text-primary); font-weight: 600; }
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.tile { background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.tile-value { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
.tile-label { color: var(--text-muted); font-size: 12.5px; }
.chart-card { background: var(--surface-1); }
.chart-head { display: flex; justify-content: space-between; align-items: center; }
.chart { position: relative; height: 240px; }
.chart.loading { opacity: .5; }
.chart svg { width: 100%; height: 100%; display: block; }
.chart .axis text { fill: var(--text-muted); font-size: 11px; }
.chart .gridline { stroke: var(--grid); stroke-width: 1; }
.chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; }
.chart .s1 { stroke: var(--series-1); }
.chart .s2 { stroke: var(--series-2); }
.chart .bar { fill: var(--series-1); }
.chart .bar.hover { fill: #6da7ec; }
.chart .crosshair { stroke: var(--text-muted); stroke-width: 1; }
.chart .marker { stroke: var(--surface-1); stroke-width: 2; }
.chart .marker.s1 { fill: var(--series-1); }
.chart .marker.s2 { fill: var(--series-2); }
.chart .endlabel { font-size: 11.5px; fill: var(--text-secondary); }
.chart .empty { fill: var(--text-muted); font-size: 13px; }
.legend { display: flex; gap: 14px; color: var(--text-secondary); font-size: 12.5px; }
.legend .key { display: inline-flex; align-items: center; gap: 6px; }
.swatch-line { width: 16px; height: 2px; display: inline-block; }
.swatch-line.s1 { background: var(--series-1); }
.swatch-line.s2 { background: var(--series-2); }
.tooltip {
position: fixed; z-index: 50; pointer-events: none; background: var(--surface-3); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 10px; font-size: 12.5px; box-shadow: 0 6px 18px rgba(0, 0, 0, .5);
}
.tooltip .t-time { color: var(--text-muted); margin-bottom: 4px; }
.tooltip .t-row { display: flex; align-items: center; gap: 8px; }
.tooltip .t-row strong { font-variant-numeric: tabular-nums; }
.tooltip .t-row .muted { margin-left: 2px; }
@media (max-width: 640px) {
.topbar { gap: 8px; }
.logout .who { display: none; }
.editor-main { inset: 96px 0 0 0; }
}

View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}PatchBay{% endblock %} - PatchBay</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{% block bodyclass %}{% endblock %}">
{% if user %}
<header class="topbar">
<span class="brand">PatchBay</span>
<nav>
{% set ep = request.endpoint %}
<a href="{{ url_for('views.patch') }}" class="{{ 'active' if ep == 'views.patch' }}">Patch</a>
<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>
</nav>
{% block toolbar %}{% endblock %}
<form method="post" action="{{ url_for('auth.logout') }}" class="logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<span class="who">{{ user.username }}{% if user.sysop %} (sysop){% endif %}</span>
<button type="submit" class="btn small">Log out</button>
</form>
</header>
{% endif %}
{% with msgs = get_flashed_messages(with_categories=true) %}
{% if msgs %}
<div class="flashes">
{% for cat, msg in msgs %}<div class="flash {{ cat }}">{{ msg }}</div>{% endfor %}
</div>
{% endif %}
{% endwith %}
<main class="{% block mainclass %}page{% endblock %}">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Log in{% endblock %}
{% block bodyclass %}auth{% endblock %}
{% block content %}
<form method="post" class="card auth-card">
<h1>PatchBay</h1>
<p class="muted">Log in to manage this gateway.</p>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Username <input name="username" autocomplete="username" required autofocus></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" class="btn primary">Continue</button>
<p class="muted small">A one-time code will be sent to your email address.</p>
</form>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Patch{% endblock %}
{% block mainclass %}editor-main{% endblock %}
{% block toolbar %}
<div class="tools">
<div class="dropdown" id="add-menu">
<button type="button" class="btn" id="add-btn" aria-haspopup="true" aria-expanded="false">Add &#9662;</button>
<div class="menu" role="menu">
<button type="button" role="menuitem" data-add="client_source">Add Client Source</button>
<button type="button" role="menuitem" data-add="client_sink">Add Client Sink</button>
<button type="button" role="menuitem" data-add="public_sink">Add Public Sink</button>
<button type="button" role="menuitem" data-add="tunnel_source">Add Tunnel Source</button>
<button type="button" role="menuitem" data-add="tunnel_sink">Add Tunnel Sink</button>
<button type="button" role="menuitem" data-add="splitter">Add Splitter</button>
</div>
</div>
<button type="button" class="btn" id="fit-btn" title="Fit all nodes into view">Fit</button>
<span id="save-state" class="save-state"></span>
</div>
{% endblock %}
{% block content %}
<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>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='common.js') }}"></script>
<script src="{{ url_for('static', filename='patch.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Services{% endblock %}
{% block content %}
<div class="page-head">
<h1>Services</h1>
<button type="button" class="btn" id="refresh-services">Refresh now</button>
</div>
{% if not daemon_ok %}<div class="flash error">patchbayd is not reachable; showing the last reported data.</div>{% endif %}
<p class="muted">Listening TCP ports and bound UDP ports on every host, with the process that owns them. Clients report every few seconds while connected.</p>
{% for h in hosts %}
<section class="card">
<h2><span class="dot {{ 'on' if h.online else 'off' }}" title="{{ 'online' if h.online else 'offline' }}"></span>{{ h.name }}</h2>
{% if h.ifaces %}
<p class="small">Tunnel interfaces:
{% for i in h.ifaces %}<span class="mono">{{ i.name }}</span>{% if i.addr != '-' %} <span class="muted mono">{{ i.addr }}</span>{% endif %}{{ ', ' if not loop.last }}{% endfor %}
</p>
{% endif %}
{% if h.services %}
<table class="grid">
<thead><tr><th>Proto</th><th>Address</th><th class="num">Port</th><th>Process</th><th class="num">PID</th></tr></thead>
<tbody>
{% for s in h.services %}
<tr><td>{{ s.proto | upper }}</td><td class="mono">{{ s.addr }}</td><td class="num mono">{{ s.port }}</td>
<td>{{ s.process }}</td><td class="num mono">{{ s.pid or '-' }}</td></tr>
{% endfor %}
</tbody>
</table>
<p class="muted small" data-ts="{{ h.updated }}">Updated <span class="ts"></span></p>
{% else %}
<p class="muted">No data reported yet.</p>
{% endif %}
</section>
{% endfor %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='common.js') }}"></script>
<script src="{{ url_for('static', filename='services.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Settings{% endblock %}
{% macro csrf() %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endmacro %}
{% block content %}
<h1>Settings</h1>
<section class="card" id="account">
<h2>My account</h2>
<form method="post" action="{{ url_for('views.settings_account') }}" class="form-grid">
{{ csrf() }}<input type="hidden" name="anchor" value="#account">
<label>Email <input name="email" type="email" value="{{ user.email }}" required></label>
<label>Current password <input name="current" type="password" autocomplete="current-password" required></label>
<label>New password <input name="new" type="password" autocomplete="new-password" minlength="10" placeholder="leave empty to keep"></label>
<label>Confirm new password <input name="confirm" type="password" autocomplete="new-password"></label>
<div class="actions"><button class="btn primary">Save</button></div>
</form>
</section>
<section class="card" id="clients">
<h2>Clients</h2>
<p class="muted">A machine can only connect once its public key is listed here. On the client, run <code>patchbayd --pubkey</code> and paste the output below.</p>
{% if clients %}
<table class="grid">
<thead><tr><th>Name</th><th>Host</th><th>Key</th><th>Last seen</th><th>Added by</th><th></th></tr></thead>
<tbody>
{% for c in clients %}
<tr>
<td>{{ c.name }}</td>
<td>{{ c.hostname or '-' }}{% if c.last_addr %} <span class="muted mono">{{ c.last_addr }}</span>{% endif %}</td>
<td class="mono key" title="{{ c.pubkey }}">{{ c.pubkey.split(' ')[0] }} ...{{ c.pubkey[-12:] }}</td>
<td data-ts="{{ c.last_seen }}"><span class="ts"></span></td>
<td>{{ c.added_by }}</td>
<td class="right">
<form method="post" action="{{ url_for('views.clients_delete', cid=c.id) }}" data-confirm="Remove client {{ c.name }}? It will be disconnected and its nodes lose their client.">
{{ csrf() }}<input type="hidden" name="anchor" value="#clients">
<button class="btn small danger">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<form method="post" action="{{ url_for('views.clients_add') }}" class="form-grid">
{{ csrf() }}<input type="hidden" name="anchor" value="#clients">
<label>Name <input name="name" required pattern="[A-Za-z0-9._\-]{1,64}" placeholder="e.g. homeserver"></label>
<label class="wide">Public key <textarea name="pubkey" rows="2" required placeholder="ssh-ed25519 AAAA... root@host"></textarea></label>
<div class="actions"><button class="btn primary">Add client</button></div>
</form>
</section>
{% if user.sysop %}
<section class="card" id="users">
<h2>Users</h2>
<p class="muted">All users can edit the patch and manage clients. Only the sysop ({{ conf.get('sysopuser') }}, defined in patchbay.conf) manages users and server settings.</p>
{% for u in users %}
<div class="user-row">
<form method="post" action="{{ url_for('views.users_edit', uid=u.id) }}" class="inline-form">
{{ csrf() }}<input type="hidden" name="anchor" value="#users">
<strong>{{ u.username }}</strong>
<input name="email" type="email" value="{{ u.email }}" required aria-label="Email">
<input name="password" type="password" minlength="10" placeholder="new password" aria-label="New password" autocomplete="new-password">
<button class="btn small">Save</button>
</form>
<form method="post" action="{{ url_for('views.users_delete', uid=u.id) }}" data-confirm="Delete user {{ u.username }}?">
{{ csrf() }}<input type="hidden" name="anchor" value="#users">
<button class="btn small danger">Delete</button>
</form>
</div>
{% else %}
<p class="muted">No other users yet.</p>
{% endfor %}
<h3>New user</h3>
<form method="post" action="{{ url_for('views.users_add') }}" class="form-grid">
{{ csrf() }}<input type="hidden" name="anchor" value="#users">
<label>Username <input name="username" required pattern="[A-Za-z0-9._\-]{1,64}"></label>
<label>Email <input name="email" type="email" required></label>
<label>Password <input name="password" type="password" minlength="10" required autocomplete="new-password"></label>
<div class="actions"><button class="btn primary">Create user</button></div>
</form>
</section>
<section class="card" id="mail">
<h2>Mail</h2>
<p class="muted">Used to send login codes. Saved to patchbay.conf.</p>
<form method="post" action="{{ url_for('views.settings_mail') }}" class="form-grid">
{{ csrf() }}<input type="hidden" name="anchor" value="#mail">
<label>SMTP server <input name="host" value="{{ conf.get('mailhost') }}" placeholder="smtp.example.org"></label>
<label>Port <input name="port" type="number" min="1" max="65535" value="{{ conf.get('mailport') }}"></label>
<label>Security
<select name="security">
{% for v, l in [('ssl', 'SSL/TLS'), ('starttls', 'STARTTLS'), ('none', 'None')] %}
<option value="{{ v }}" {{ 'selected' if conf.get('mailsecurity') == v }}>{{ l }}</option>
{% endfor %}
</select>
</label>
<label>Username <input name="user" value="{{ conf.get('mailuser') }}" autocomplete="off"></label>
<label>Password <input name="password" type="password" placeholder="{{ 'unchanged' if conf.get('mailpassword') else '' }}" autocomplete="new-password"></label>
<label>From address <input name="from" value="{{ conf.get('mailfrom') }}"></label>
<div class="actions"><button class="btn primary">Save</button></div>
</form>
<form method="post" action="{{ url_for('views.settings_mail_test') }}">
{{ csrf() }}<input type="hidden" name="anchor" value="#mail">
<button class="btn">Send test mail to {{ user.email }}</button>
</form>
</section>
<section class="card" id="retention">
<h2>Statistics retention</h2>
<form method="post" action="{{ url_for('views.settings_retention') }}" class="form-grid">
{{ csrf() }}<input type="hidden" name="anchor" value="#retention">
<label>Per-minute data (hours) <input name="stats_minute_hours" type="number" min="1" value="{{ retention.stats_minute_hours }}"></label>
<label>Hourly data (days) <input name="stats_hour_days" type="number" min="1" value="{{ retention.stats_hour_days }}"></label>
<label>Daily data (days, 0 = forever) <input name="stats_day_days" type="number" min="0" value="{{ retention.stats_day_days }}"></label>
<div class="actions"><button class="btn primary">Save</button></div>
</form>
</section>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='common.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}Stats{% endblock %}
{% block content %}
<h1>Stats</h1>
{% if not sinks %}
<p class="muted">No connections yet. Patch a source into a sink on the Patch page first.</p>
{% else %}
<div class="filter-row">
<label>Connection
<select id="sink-select">
{% for s in sinks %}
<option value="{{ s.id }}">#{{ s.id }} {% if s.label %}{{ s.label }} - {% endif %}{% if s.type == 'public_sink' %}public {{ s.host or '0.0.0.0' }}{% elif s.type == 'tunnel_sink' %}{{ s.client or 'target' }} {{ s.iface or '?' }}{% else %}{{ s.client or '?' }} {{ s.host or '127.0.0.1' }}{% endif %}:{{ s.port }}/{{ s.proto }}</option>
{% endfor %}
</select>
</label>
<div class="segmented" role="group" aria-label="Time range" id="range-group">
{% for r in ['1h', '24h', '7d', '30d', '1y', 'all'] %}
<button type="button" data-range="{{ r }}" class="{{ 'selected' if r == '24h' }}">{{ r }}</button>
{% endfor %}
</div>
</div>
<div class="tiles">
<div class="tile"><div class="tile-value" id="t-in">-</div><div class="tile-label">In now (towards source)</div></div>
<div class="tile"><div class="tile-value" id="t-out">-</div><div class="tile-label">Out now (back to peer)</div></div>
<div class="tile"><div class="tile-value" id="t-active">-</div><div class="tile-label">Open connections</div></div>
<div class="tile"><div class="tile-value" id="t-total">-</div><div class="tile-label">Transferred in selected range</div></div>
</div>
<section class="card chart-card" id="chart-rate">
<div class="chart-head">
<h2>Throughput</h2>
<div class="legend">
<span class="key"><span class="swatch-line s1"></span>In</span>
<span class="key"><span class="swatch-line s2"></span>Out</span>
</div>
</div>
<div class="chart" data-kind="rate"></div>
</section>
<section class="card chart-card" id="chart-conns">
<div class="chart-head"><h2>New connections</h2></div>
<div class="chart" data-kind="conns"></div>
</section>
<details class="card">
<summary>Table view</summary>
<div class="table-wrap">
<table class="grid" id="stats-table">
<thead><tr><th>Time</th><th class="num">In</th><th class="num">Out</th><th class="num">Connections</th></tr></thead>
<tbody></tbody>
</table>
</div>
</details>
<div id="chart-tooltip" class="tooltip" hidden></div>
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='common.js') }}"></script>
<script src="{{ url_for('static', filename='stats.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Login code{% endblock %}
{% block bodyclass %}auth{% endblock %}
{% block content %}
<form method="post" class="card auth-card">
<h1>Check your email</h1>
<p class="muted">Enter the 6-digit code we sent you. It is valid for 10 minutes.</p>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Code <input name="code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
autocomplete="one-time-code" required autofocus class="code"></label>
<button type="submit" class="btn primary">Log in</button>
<p class="small"><a href="{{ url_for('auth.login') }}">Start over</a></p>
</form>
{% endblock %}

42
web/patchbay_web/tls.py Normal file
View File

@@ -0,0 +1,42 @@
"""Self-signed certificate generation for the first start."""
import datetime
import ipaddress
import os
import socket
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
def ensure_cert(cert_path, key_path):
if os.path.exists(cert_path) and os.path.exists(key_path):
return False
host = socket.gethostname()
key = ec.generate_private_key(ec.SECP256R1())
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "PatchBay")])
sans = [x509.DNSName(host), x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]
now = datetime.datetime.now(datetime.timezone.utc)
cert = (x509.CertificateBuilder()
.subject_name(name).issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(minutes=5))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.SubjectAlternativeName(sans), critical=False)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(key, hashes.SHA256()))
old = os.umask(0o077)
try:
with open(key_path, "wb") as f:
f.write(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
finally:
os.umask(old)
with open(cert_path, "wb") as f:
f.write(cert.public_bytes(serialization.Encoding.PEM))
return True

View File

@@ -0,0 +1,141 @@
"""Input validation for client keys, users and the patch graph."""
import base64
import binascii
import re
import struct
KEY_TYPES = {
"ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com",
}
NODE_TYPES = {"client_source", "client_sink", "public_sink", "splitter", "tunnel_source", "tunnel_sink"}
SOURCES = {"client_source", "tunnel_source"}
HAS_OUTPUT = SOURCES | {"splitter"}
HAS_INPUT = {"client_sink", "public_sink", "splitter", "tunnel_sink"}
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}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class Invalid(ValueError):
pass
def pubkey(text):
"""Returns the normalised "type base64" form of an OpenSSH public key line."""
parts = text.strip().split()
if len(parts) < 2:
raise Invalid("expected an OpenSSH public key line (type base64 [comment])")
ktype, b64 = parts[0], parts[1]
if ktype not in KEY_TYPES:
raise Invalid(f"unsupported key type {ktype}")
try:
blob = base64.b64decode(b64, validate=True)
(n,) = struct.unpack(">I", blob[:4])
inner = blob[4:4 + n].decode()
except (binascii.Error, struct.error, UnicodeDecodeError):
raise Invalid("key data is not valid base64") from None
if inner != ktype:
raise Invalid("key type does not match key data")
return f"{ktype} {b64}"
def name(text, what="name"):
if not NAME_RE.match(text or ""):
raise Invalid(f"{what} may only contain letters, digits, '.', '_' and '-' (max 64)")
return text
def email(text):
if not EMAIL_RE.match(text or "") or len(text) > 254:
raise Invalid("invalid email address")
return text
def password(text):
if len(text or "") < 10:
raise Invalid("password must be at least 10 characters")
return text
def _port(v):
try:
p = int(v or 0)
except (TypeError, ValueError):
raise Invalid("port must be a number") from None
if not 0 <= p <= 65535:
raise Invalid("port out of range")
return p
def graph(data, client_ids):
"""Validates the editor payload. Returns (nodes, links) with clean values.
Node ids are positive for existing nodes and negative for new ones.
"""
if not isinstance(data, dict):
raise Invalid("bad payload")
nodes, ids = [], set()
for n in data.get("nodes", []):
t = n.get("type")
if t not in NODE_TYPES:
raise Invalid(f"unknown node type {t}")
nid = int(n.get("id", 0))
if nid == 0 or nid in ids:
raise Invalid("duplicate or missing node id")
ids.add(nid)
cid = n.get("client_id")
cid = int(cid) if cid not in (None, "", 0, "0") else None
if cid is not None and cid not in client_ids:
raise Invalid("unknown client")
if t in ("public_sink", "splitter"):
cid = None
iface = str(n.get("iface") or "").strip()
if t in ("tunnel_source", "tunnel_sink"):
# client_id None = the target's own interface.
if iface and not IFACE_RE.match(iface):
raise Invalid(f"invalid interface name {iface!r}")
else:
iface = ""
host = str(n.get("host") or "").strip()
if not HOST_RE.match(host):
raise Invalid(f"invalid address {host!r}")
proto = n.get("proto", "tcp")
if proto not in ("tcp", "udp"):
raise Invalid("protocol must be tcp or udp")
label = str(n.get("label") or "")[:64]
nodes.append({
"id": nid, "type": t, "client_id": cid, "host": host, "port": _port(n.get("port")),
"proto": proto, "label": label, "iface": iface,
"x": float(n.get("x", 0)), "y": float(n.get("y", 0)),
})
types = {n["id"]: n["type"] for n in nodes}
links, inputs, source_out = [], set(), set()
for ln in data.get("links", []):
a, b = int(ln.get("from", 0)), int(ln.get("to", 0))
if a not in types or b not in types or a == b:
raise Invalid("link refers to unknown node")
if types[a] not in HAS_OUTPUT or types[b] not in HAS_INPUT:
raise Invalid("link direction invalid")
if b in inputs:
raise Invalid("an input can only have one link")
if types[a] in SOURCES:
if a in source_out:
raise Invalid("a source output can only have one link; use a splitter")
source_out.add(a)
inputs.add(b)
links.append((a, b))
# Reject cycles through splitters.
parent = dict((b, a) for a, b in links)
for start in types:
seen, cur = set(), start
while cur in parent:
if cur in seen:
raise Invalid("patch contains a loop")
seen.add(cur)
cur = parent[cur]
return nodes, links

232
web/patchbay_web/views.py Normal file
View File

@@ -0,0 +1,232 @@
"""Server-rendered pages and the Settings form actions."""
from flask import Blueprint, current_app, flash, g, redirect, render_template, request, url_for
from . import auth, daemon, db, mailer, security, validate
from .auth import login_required, sysop_required
bp = Blueprint("views", __name__)
def conf():
return current_app.config["PB_CONF"]
@bp.get("/")
@login_required
def index():
return redirect(url_for("views.patch"))
@bp.get("/patch")
@login_required
def patch():
return render_template("patch.html")
@bp.get("/services")
@login_required
def services():
conn = db.get()
hosts = [{"id": 0, "name": "Target (this server)", "online": True}]
status = daemon.request("STATUS")
online = status.get("clients", {})
for r in conn.execute("SELECT id, name, hostname FROM clients ORDER BY name"):
label = r["name"] + (f" ({r['hostname']})" if r["hostname"] else "")
hosts.append({"id": r["id"], "name": label, "online": str(r["id"]) in online})
rows = conn.execute("SELECT client_id, proto, addr, port, pid, process, updated FROM services "
"ORDER BY client_id, proto, port").fetchall()
ifaces = conn.execute("SELECT client_id, name, addr FROM interfaces ORDER BY name").fetchall()
for h in hosts:
h["services"] = [r for r in rows if r["client_id"] == h["id"]]
h["ifaces"] = [r for r in ifaces if r["client_id"] == h["id"]]
h["updated"] = max((r["updated"] for r in h["services"]), default=0)
return render_template("services.html", hosts=hosts, daemon_ok=status.get("ok", False))
@bp.get("/stats")
@login_required
def stats():
conn = db.get()
sinks = conn.execute(
"SELECT s.id, s.type, s.host, s.port, s.proto, s.label, s.iface, c.name AS client "
"FROM nodes s LEFT JOIN clients c ON c.id = s.client_id "
"WHERE s.type IN ('public_sink', 'client_sink', 'tunnel_sink') ORDER BY s.id").fetchall()
return render_template("stats.html", sinks=sinks)
@bp.get("/settings")
@login_required
def settings():
conn = db.get()
users = conn.execute("SELECT id, username, email, created FROM users ORDER BY username").fetchall()
clients = conn.execute("SELECT id, name, pubkey, added_by, created, hostname, last_seen, last_addr "
"FROM clients ORDER BY name").fetchall()
retention = {k: db.setting(k, "") for k in ("stats_minute_hours", "stats_hour_days", "stats_day_days")}
return render_template("settings.html", users=users, clients=clients, retention=retention, conf=conf())
def _done(msg):
flash(msg, "ok")
return redirect(url_for("views.settings") + request.form.get("anchor", ""))
def _fail(msg):
flash(msg, "error")
return redirect(url_for("views.settings") + request.form.get("anchor", ""))
@bp.post("/settings/account")
@login_required
def settings_account():
f = request.form
if not security.verify_password(f.get("current", ""), g.user["pwhash"]):
return _fail("Current password is wrong.")
try:
email = validate.email(f.get("email", "").strip())
new = f.get("new", "")
if new:
validate.password(new)
if new != f.get("confirm", ""):
raise validate.Invalid("new passwords do not match")
except validate.Invalid as e:
return _fail(str(e).capitalize() + ".")
if g.user["sysop"]:
changes = {"SysopEmail": email}
if new:
changes["SysopPassword"] = security.hash_password(new)
conf().update(changes)
else:
conn = db.get()
conn.execute("UPDATE users SET email = ? WHERE username = ?", (email, g.user["username"]))
if new:
conn.execute("UPDATE users SET pwhash = ? WHERE username = ?",
(security.hash_password(new), g.user["username"]))
auth.refresh_pwtag()
return _done("Account updated.")
@bp.post("/settings/users/add")
@sysop_required
def users_add():
f = request.form
try:
username = validate.name(f.get("username", "").strip(), "username")
email = validate.email(f.get("email", "").strip())
pw = validate.password(f.get("password", ""))
except validate.Invalid as e:
return _fail(str(e).capitalize() + ".")
if username == conf().get("sysopuser"):
return _fail("That name belongs to the sysop.")
conn = db.get()
if conn.execute("SELECT 1 FROM users WHERE username = ?", (username,)).fetchone():
return _fail("User already exists.")
conn.execute("INSERT INTO users (username, email, pwhash, created) VALUES (?, ?, ?, ?)",
(username, email, security.hash_password(pw), db.now()))
return _done(f"User {username} created.")
@bp.post("/settings/users/<int:uid>/edit")
@sysop_required
def users_edit(uid):
f = request.form
conn = db.get()
try:
email = validate.email(f.get("email", "").strip())
conn.execute("UPDATE users SET email = ? WHERE id = ?", (email, uid))
if f.get("password"):
conn.execute("UPDATE users SET pwhash = ? WHERE id = ?",
(security.hash_password(validate.password(f["password"])), uid))
except validate.Invalid as e:
return _fail(str(e).capitalize() + ".")
return _done("User updated.")
@bp.post("/settings/users/<int:uid>/delete")
@sysop_required
def users_delete(uid):
db.get().execute("DELETE FROM users WHERE id = ?", (uid,))
return _done("User deleted.")
@bp.post("/settings/clients/add")
@login_required
def clients_add():
f = request.form
try:
name = validate.name(f.get("name", "").strip(), "client name")
key = validate.pubkey(f.get("pubkey", ""))
except validate.Invalid as e:
return _fail(str(e).capitalize() + ".")
conn = db.get()
if conn.execute("SELECT 1 FROM clients WHERE name = ? OR pubkey = ?", (name, key)).fetchone():
return _fail("A client with that name or key already exists.")
conn.execute("INSERT INTO clients (name, pubkey, added_by, created) VALUES (?, ?, ?, ?)",
(name, key, g.user["username"], db.now()))
res = daemon.reload()
if not res.get("ok"):
flash(f"Client saved, but the daemon did not reload: {res.get('error')}", "error")
return _done(f"Client {name} added.")
@bp.post("/settings/clients/<int:cid>/delete")
@login_required
def clients_delete(cid):
conn = db.get()
conn.execute("DELETE FROM services WHERE client_id = ?", (cid,))
conn.execute("DELETE FROM clients WHERE id = ?", (cid,))
daemon.reload()
return _done("Client removed.")
@bp.post("/settings/mail")
@sysop_required
def settings_mail():
f = request.form
security_mode = f.get("security", "starttls")
if security_mode not in ("ssl", "starttls", "none"):
return _fail("Invalid security mode.")
try:
port = int(f.get("port", "587"))
except ValueError:
return _fail("Port must be a number.")
changes = {
"MailHost": f.get("host", "").strip(),
"MailPort": port,
"MailSecurity": security_mode,
"MailUser": f.get("user", "").strip(),
"MailFrom": f.get("from", "").strip(),
}
if f.get("password"):
changes["MailPassword"] = f["password"]
try:
conf().update(changes)
except ValueError as e:
return _fail(str(e))
return _done("Mail settings saved.")
@bp.post("/settings/mail/test")
@sysop_required
def settings_mail_test():
try:
mailer.send(conf(), g.user["email"], "PatchBay test mail",
"This is a test message from your PatchBay server.\n")
except mailer.MailError as e:
return _fail(f"Sending failed: {e}")
return _done(f"Test mail sent to {g.user['email']}.")
@bp.post("/settings/retention")
@sysop_required
def settings_retention():
try:
vals = {k: int(request.form.get(k, "0")) for k in ("stats_minute_hours", "stats_hour_days", "stats_day_days")}
except ValueError:
return _fail("Retention values must be whole numbers.")
if any(v < 0 for v in vals.values()) or vals["stats_minute_hours"] == 0 or vals["stats_hour_days"] == 0:
return _fail("Minute and hour retention must be positive; day retention 0 means forever.")
for k, v in vals.items():
db.set_setting(k, v)
return _done("Retention saved.")

0
web/tests/__init__.py Normal file
View File

55
web/tests/helpers.py Normal file
View File

@@ -0,0 +1,55 @@
import os
import re
import shutil
import tempfile
import unittest
from unittest import mock
from patchbay_web import create_app
ED25519 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINJ2M1DCOTYJkGHaPO2iMGqF7isZXmwqN5HxtLP5+xq1 test@host"
class AppCase(unittest.TestCase):
"""Fresh config + database per test; mails are captured instead of sent."""
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="pbtest.")
self.conf_path = os.path.join(self.dir, "patchbay.conf")
with open(self.conf_path, "w") as f:
f.write("# test config\nRole = target\n"
f"Database = {self.dir}/patchbay.db\nRunDir = {self.dir}\n"
"SysopUser = admin\nSysopEmail = admin@example.org\n"
"SysopPassword = correct horse battery\nMailHost = smtp.invalid\n")
self.mails = []
patcher = mock.patch("patchbay_web.mailer.send",
side_effect=lambda conf, to, subj, body: self.mails.append((to, body)))
patcher.start()
self.addCleanup(patcher.stop)
self.app = create_app(self.conf_path, testing=True)
self.client = self.app.test_client()
def tearDown(self):
shutil.rmtree(self.dir)
def csrf(self):
page = self.client.get("/login").get_data(as_text=True)
m = re.search(r'name="csrf-token" content="([^"]+)"', page)
return m.group(1)
def login(self, user="admin", password="correct horse battery"):
tok = self.csrf()
r = self.client.post("/login", data={"username": user, "password": password, "csrf_token": tok})
if r.status_code != 302:
return r
code = re.search(r"code is: (\d{6})", self.mails[-1][1]).group(1)
page = self.client.get("/verify").get_data(as_text=True)
tok = re.search(r'name="csrf-token" content="([^"]+)"', page).group(1)
return self.client.post("/verify", data={"code": code, "csrf_token": tok})
def token_from(self, path):
page = self.client.get(path).get_data(as_text=True)
return re.search(r'name="csrf-token" content="([^"]+)"', page).group(1)
def token(self):
return self.token_from("/settings")

75
web/tests/test_auth.py Normal file
View File

@@ -0,0 +1,75 @@
import unittest
from patchbay_web import security
from patchbay_web.conf import Config
from .helpers import AppCase
class AuthTest(AppCase):
def test_sysop_password_hashed_on_start(self):
conf = Config(self.conf_path)
self.assertTrue(conf.get("sysoppassword").startswith("$scrypt$"))
with open(self.conf_path) as f:
self.assertIn("# test config", f.read())
def test_login_2fa(self):
r = self.login()
self.assertEqual(r.status_code, 302)
self.assertEqual(self.mails[-1][0], "admin@example.org")
self.assertEqual(self.client.get("/patch").status_code, 200)
def test_wrong_password(self):
r = self.login(password="nope")
self.assertEqual(r.status_code, 401)
self.assertEqual(self.mails, [])
def test_wrong_code_then_expired_attempts(self):
tok = self.csrf()
self.client.post("/login", data={"username": "admin", "password": "correct horse battery", "csrf_token": tok})
tok = self.token_from("/verify")
for _ in range(5):
r = self.client.post("/verify", data={"code": "000000", "csrf_token": tok})
r = self.client.post("/verify", data={"code": "000000", "csrf_token": tok})
self.assertEqual(r.status_code, 302) # sent back to login
self.assertEqual(self.client.get("/patch").status_code, 302)
def test_csrf_required(self):
self.login()
r = self.client.post("/settings/users/add", data={"username": "bob"})
self.assertEqual(r.status_code, 400)
def test_requires_login(self):
self.assertEqual(self.client.get("/settings").status_code, 302)
self.assertEqual(self.client.get("/api/graph").status_code, 401)
def test_user_management_sysop_only(self):
self.login()
tok = self.token()
r = self.client.post("/settings/users/add", data={
"username": "bob", "email": "bob@example.org", "password": "0123456789x", "csrf_token": tok})
self.assertEqual(r.status_code, 302)
self.client.post("/logout", data={"csrf_token": tok})
self.assertEqual(self.login("bob", "0123456789x").status_code, 302)
tok = self.token()
r = self.client.post("/settings/users/add", data={
"username": "eve", "email": "e@example.org", "password": "0123456789x", "csrf_token": tok})
self.assertEqual(r.status_code, 403)
def test_rate_limit(self):
for _ in range(10):
self.login(password="bad")
self.assertEqual(self.login().status_code, 429)
class SecurityTest(unittest.TestCase):
def test_hash_roundtrip(self):
h = security.hash_password("hunter22")
self.assertTrue(security.verify_password("hunter22", h))
self.assertFalse(security.verify_password("hunter23", h))
self.assertFalse(security.verify_password("x", None))
if __name__ == "__main__":
unittest.main()

27
web/tests/test_conf.py Normal file
View File

@@ -0,0 +1,27 @@
import os
import tempfile
import unittest
from patchbay_web.conf import Config
class ConfTest(unittest.TestCase):
def test_update_preserves_layout(self):
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "patchbay.conf")
with open(p, "w") as f:
f.write("# head\nRole = target\nmailhost = old\nMailHost = dup\n")
os.chmod(p, 0o600)
c = Config(p)
self.assertEqual(c.get("MailHost"), "dup")
c.update({"MailHost": "smtp.example.org", "MailPort": 465})
with open(p) as f:
text = f.read()
self.assertEqual(text, "# head\nRole = target\nmailhost = smtp.example.org\nMailPort = 465\n")
self.assertEqual(os.stat(p).st_mode & 0o777, 0o600)
with self.assertRaises(ValueError):
c.update({"MailHost": "a\nRole = client"})
if __name__ == "__main__":
unittest.main()

96
web/tests/test_graph.py Normal file
View File

@@ -0,0 +1,96 @@
import unittest
from patchbay_web import validate
from .helpers import ED25519, AppCase
class ValidateTest(unittest.TestCase):
def test_pubkey(self):
self.assertEqual(validate.pubkey(ED25519), " ".join(ED25519.split()[:2]))
for bad in ["", "ssh-ed25519", "ssh-dss AAAA", "ssh-ed25519 !!!!",
'ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAINJ2M1DCOTYJkGHaPO2iMGqF7isZXmwqN5HxtLP5+xq1']:
with self.assertRaises(validate.Invalid):
validate.pubkey(bad)
def test_graph_rules(self):
src = {"id": -1, "type": "client_source", "client_id": 1, "port": 22, "proto": "tcp"}
sink = {"id": -2, "type": "public_sink", "host": "0.0.0.0", "port": 2200, "proto": "tcp"}
split = {"id": -3, "type": "splitter"}
nodes, links = validate.graph({"nodes": [src, sink], "links": [{"from": -1, "to": -2}]}, {1})
self.assertEqual(links, [(-1, -2)])
with self.assertRaises(validate.Invalid): # wrong direction
validate.graph({"nodes": [src, sink], "links": [{"from": -2, "to": -1}]}, {1})
with self.assertRaises(validate.Invalid): # unknown client
validate.graph({"nodes": [src], "links": []}, set())
with self.assertRaises(validate.Invalid): # source with two outputs
sink2 = dict(sink, id=-4)
validate.graph({"nodes": [src, sink, sink2],
"links": [{"from": -1, "to": -2}, {"from": -1, "to": -4}]}, {1})
with self.assertRaises(validate.Invalid): # loop through splitters
split2 = dict(split, id=-5)
validate.graph({"nodes": [split, split2],
"links": [{"from": -3, "to": -5}, {"from": -5, "to": -3}]}, {1})
with self.assertRaises(validate.Invalid):
validate.graph({"nodes": [dict(sink, host="a b")], "links": []}, {1})
def test_tunnel_nodes(self):
tsrc = {"id": -1, "type": "tunnel_source", "client_id": None, "host": "10.100.0.2", "port": 25565,
"proto": "tcp", "iface": "tun0"}
tsink = {"id": -2, "type": "tunnel_sink", "client_id": 1, "port": 8080, "proto": "tcp", "iface": "tun1"}
nodes, links = validate.graph({"nodes": [tsrc, tsink], "links": [{"from": -1, "to": -2}]}, {1})
self.assertEqual(nodes[0]["iface"], "tun0")
self.assertIsNone(nodes[0]["client_id"])
self.assertEqual(links, [(-1, -2)])
with self.assertRaises(validate.Invalid):
validate.graph({"nodes": [dict(tsrc, iface="tun0; rm")], "links": []}, {1})
# iface is ignored on non-tunnel nodes
n, _ = validate.graph({"nodes": [{"id": -3, "type": "public_sink", "port": 1, "iface": "x"}], "links": []}, {1})
self.assertEqual(n[0]["iface"], "")
class GraphApiTest(AppCase):
def test_roundtrip_keeps_ids(self):
self.login()
tok = self.token()
r = self.client.post("/settings/clients/add", data={"name": "c1", "pubkey": ED25519, "csrf_token": tok})
self.assertEqual(r.status_code, 302)
cid = self.client.get("/api/graph").get_json()["clients"][0]["id"]
payload = {
"nodes": [
{"id": -1, "type": "client_source", "client_id": cid, "host": "127.0.0.1", "port": 22, "proto": "tcp", "x": 0, "y": 0},
{"id": -2, "type": "splitter", "x": 100, "y": 0},
{"id": -3, "type": "public_sink", "host": "0.0.0.0", "port": 2200, "proto": "tcp", "x": 200, "y": 0},
],
"links": [{"from": -1, "to": -2}, {"from": -2, "to": -3}],
}
r = self.client.put("/api/graph", json=payload, headers={"X-CSRF-Token": tok})
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
ids = r.get_json()["ids"]
self.assertFalse(r.get_json()["daemon"]) # no daemon in tests
g = self.client.get("/api/graph").get_json()
self.assertEqual(len(g["nodes"]), 3)
sink_id = ids["-3"]
# Saving again with real ids keeps them (stats are keyed by sink id).
for n in payload["nodes"]:
n["id"] = ids[str(n["id"])]
payload["links"] = [{"from": ids["-1"], "to": ids["-2"]}, {"from": ids["-2"], "to": ids["-3"]}]
r = self.client.put("/api/graph", json=payload, headers={"X-CSRF-Token": tok})
self.assertEqual(r.get_json()["ids"][str(sink_id)], sink_id)
# Deleting the client keeps its nodes but clears the reference.
self.client.post(f"/settings/clients/{cid}/delete", data={"csrf_token": tok})
g = self.client.get("/api/graph").get_json()
self.assertIsNone(next(n for n in g["nodes"] if n["type"] == "client_source")["client_id"])
def test_invalid_graph_rejected(self):
self.login()
tok = self.token()
r = self.client.put("/api/graph", json={"nodes": [{"id": -1, "type": "bogus"}]}, headers={"X-CSRF-Token": tok})
self.assertEqual(r.status_code, 400)
if __name__ == "__main__":
unittest.main()