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

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
build
**/__pycache__
.git
smtp-test-data.conf

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
build/
__pycache__/
smtp-test-data.conf
# LLM wrapper files and folders (please dont use them here)
AGENTS.md
AGENT.md
.claude/
CLAUDE.md
.openai/
CODEX.md
.opencode/
# If I missed any, lmk

69
Makefile Normal file
View File

@@ -0,0 +1,69 @@
# PatchBay top-level Makefile
PREFIX ?= /usr/local
SBINDIR ?= $(PREFIX)/sbin
LIBDIR ?= $(PREFIX)/lib/patchbay
DESTDIR ?=
CC ?= cc
CFLAGS ?= -O2 -g
CFLAGS += -std=c99 -D_GNU_SOURCE -Wall -Wextra -Wno-unused-parameter
PKGS := libssh2 sqlite3
PKG_CFLAGS := $(shell pkg-config --cflags $(PKGS))
PKG_LIBS := $(shell pkg-config --libs $(PKGS))
BUILD := build
SRC := backend/src
COMMON := config log net proto json services db stats
OBJS_ALL := $(addprefix $(BUILD)/,$(addsuffix .o,$(COMMON) hub client relay main))
OBJS_TEST := $(addprefix $(BUILD)/,$(addsuffix .o,config log net proto json services))
PYTHON ?= python3
.PHONY: all test test-c test-web install clean docker-test
all: $(BUILD)/patchbayd
$(BUILD):
mkdir -p $(BUILD)
# Embed schema.sql as a C string so the daemon has no runtime file dependency.
$(BUILD)/schema.h: schema.sql | $(BUILD)
{ printf 'static const char pb_schema_sql[] =\n'; \
sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/^/"/' -e 's/$$/\\n"/' $<; \
printf ';\n'; } > $@
$(BUILD)/%.o: $(SRC)/%.c $(wildcard $(SRC)/*.h) $(BUILD)/schema.h | $(BUILD)
$(CC) $(CFLAGS) $(PKG_CFLAGS) -I$(BUILD) -c -o $@ $<
$(BUILD)/patchbayd: $(OBJS_ALL)
$(CC) $(CFLAGS) -o $@ $^ $(PKG_LIBS)
$(BUILD)/test_runner: backend/tests/test_main.c $(OBJS_TEST)
$(CC) $(CFLAGS) $(PKG_CFLAGS) -I$(BUILD) -o $@ $^ $(PKG_LIBS)
test: test-c test-web
# Single C test: make test-c T=services_parse
test-c: $(BUILD)/test_runner
$(BUILD)/test_runner $(T)
# Single web test: make test-web T=tests.test_auth.AuthTest.test_login_2fa
test-web:
cd web && $(PYTHON) -m unittest $(if $(T),$(T),discover -s tests -t .) -v
install: all
install -Dm755 $(BUILD)/patchbayd $(DESTDIR)$(SBINDIR)/patchbayd
install -Dm755 sshd/patchbay-sshd $(DESTDIR)$(LIBDIR)/patchbay-sshd
install -Dm644 sshd/sshd_config.in $(DESTDIR)$(LIBDIR)/sshd_config.in
mkdir -p $(DESTDIR)$(LIBDIR)/web
cp -r web/patchbay_web $(DESTDIR)$(LIBDIR)/web/
install -Dm644 schema.sql $(DESTDIR)$(LIBDIR)/web/patchbay_web/schema.sql
rm -rf $(DESTDIR)$(LIBDIR)/web/patchbay_web/__pycache__
# Builds the images and runs the target + clients integration test.
docker-test:
docker/run-tests.sh
clean:
rm -rf $(BUILD)

30
README.md Normal file
View File

@@ -0,0 +1,30 @@
# PatchBay
Port forwarding and routing between Linux machines over SSH, managed from a node-based web UI. One machine is the target (exit gateway, runs the web UI); every other machine is a client that connects to it.
## Installing
```sh
./install.sh --role target # on the gateway
./install.sh --role client # on every other machine
```
The script installs dependencies via APT or XBPS, builds, installs to `/usr/local` (override with `PREFIX=`), creates `/etc/patchbay/patchbay.conf` from `examples/` and installs systemd, runit or OpenRC services (`--init` to choose, `--no-deps` to skip packages).
Dependencies: a C99 compiler, make, pkg-config, libssh2 (1.11+ recommended for AES-GCM), SQLite 3; on the target also OpenSSH server, Python 3 with Flask and cryptography.
## Usage
1. Target: set the sysop account and mail server in `/etc/patchbay/patchbay.conf`, start `patchbayd`, `patchbay-sshd` and `patchbay-web`, open `https://<target>:8443`.
2. Client: set `TargetHost` in the config, run `patchbayd --pubkey` and add the key under Settings -> Clients, start `patchbayd`.
3. On the Patch page, wire a Client Source into a Public Sink (exposed on the target) or a Client Sink (exposed on another client), directly or through a Splitter.
## Building and testing
```sh
make # build/patchbayd
make test # C unit tests + web tests
make test-c T=lines # single C test
make test-web T=tests.test_auth.AuthTest.test_login_2fa
make docker-test # end-to-end: target + 2 clients in Docker
```

1091
backend/src/client.c Normal file

File diff suppressed because it is too large Load Diff

13
backend/src/client.h Normal file
View File

@@ -0,0 +1,13 @@
#ifndef PB_CLIENT_H
#define PB_CLIENT_H
#include "config.h"
// Runs the client role: keeps an SSH session to the target, reconnecting with
// backoff, until SIGTERM/SIGINT.
int client_run(const struct pb_config *cfg);
// Resolves the identity file (IdentityFile, root's key, host key) into out.
int client_identity(const struct pb_config *cfg, char *out, size_t outsz);
#endif

129
backend/src/config.c Normal file
View File

@@ -0,0 +1,129 @@
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include "config.h"
#include "log.h"
void config_defaults(struct pb_config *c)
{
memset(c, 0, sizeof(*c));
c->role = ROLE_CLIENT;
snprintf(c->db_path, sizeof(c->db_path), "%s", PB_DEFAULT_DB);
snprintf(c->run_dir, sizeof(c->run_dir), "%s", PB_RUN_DIR);
c->log_level = LOG_LVL_INFO;
c->target_port = 2222;
snprintf(c->known_hosts, sizeof(c->known_hosts), "/etc/patchbay/known_hosts");
snprintf(c->ssh_user, sizeof(c->ssh_user), "patchbay");
c->services_interval = 30;
c->ssh_port = 0; // 0 = use target_port
c->hub_port = 7701;
snprintf(c->sshd_host_key, sizeof(c->sshd_host_key), "/etc/patchbay/ssh_host_ed25519_key");
snprintf(c->authorized_keys, sizeof(c->authorized_keys), "/etc/patchbay/authorized_keys");
snprintf(c->daemon_path, sizeof(c->daemon_path), "/usr/local/sbin/patchbayd");
}
static char *trim(char *s)
{
while (isspace((unsigned char)*s))
s++;
char *e = s + strlen(s);
while (e > s && isspace((unsigned char)e[-1]))
*--e = '\0';
return s;
}
static void set_str(char *dst, size_t n, const char *v)
{
snprintf(dst, n, "%s", v);
}
#define STR(field) set_str(c->field, sizeof(c->field), val)
int config_parse_line(struct pb_config *c, char *line)
{
char *s = trim(line);
if (*s == '\0' || *s == '#' || *s == ';' || *s == '[')
return 0;
char *eq = strchr(s, '=');
if (!eq)
return -1;
*eq = '\0';
char *key = trim(s);
char *val = trim(eq + 1);
// Allow optional quoting so values with leading/trailing spaces survive.
size_t vl = strlen(val);
if (vl >= 2 && val[0] == '"' && val[vl - 1] == '"') {
val[vl - 1] = '\0';
val++;
}
if (!strcasecmp(key, "Role")) {
if (!strcasecmp(val, "target"))
c->role = ROLE_TARGET;
else if (!strcasecmp(val, "client"))
c->role = ROLE_CLIENT;
else
return -1;
} else if (!strcasecmp(key, "Database")) {
STR(db_path);
} else if (!strcasecmp(key, "RunDir")) {
STR(run_dir);
} else if (!strcasecmp(key, "LogLevel")) {
if (!strcasecmp(val, "error")) c->log_level = LOG_LVL_ERR;
else if (!strcasecmp(val, "warn")) c->log_level = LOG_LVL_WARN;
else if (!strcasecmp(val, "info")) c->log_level = LOG_LVL_INFO;
else if (!strcasecmp(val, "debug")) c->log_level = LOG_LVL_DEBUG;
else return -1;
} else if (!strcasecmp(key, "TargetHost")) {
STR(target_host);
} else if (!strcasecmp(key, "TargetPort")) {
c->target_port = atoi(val);
} else if (!strcasecmp(key, "IdentityFile")) {
STR(identity_file);
} else if (!strcasecmp(key, "IdentityPassphrase")) {
STR(identity_pass);
} else if (!strcasecmp(key, "KnownHosts")) {
STR(known_hosts);
} else if (!strcasecmp(key, "SSHUser")) {
STR(ssh_user);
} else if (!strcasecmp(key, "ServicesInterval")) {
c->services_interval = atoi(val);
} else if (!strcasecmp(key, "HubPort")) {
c->hub_port = atoi(val);
} else if (!strcasecmp(key, "SSHHostKey")) {
STR(sshd_host_key);
} else if (!strcasecmp(key, "AuthorizedKeys")) {
STR(authorized_keys);
} else if (!strcasecmp(key, "DaemonPath")) {
STR(daemon_path);
}
// Anything else belongs to the web frontend.
return 0;
}
int config_load(struct pb_config *c, const char *path)
{
FILE *f = fopen(path, "r");
if (!f)
return -1;
char line[1024];
int lineno = 0;
while (fgets(line, sizeof(line), f)) {
lineno++;
if (config_parse_line(c, line) < 0)
log_warn("%s:%d: invalid line ignored", path, lineno);
}
fclose(f);
if (c->ssh_port == 0)
c->ssh_port = c->target_port;
if (c->services_interval < 5)
c->services_interval = 5;
return 0;
}

39
backend/src/config.h Normal file
View File

@@ -0,0 +1,39 @@
#ifndef PB_CONFIG_H
#define PB_CONFIG_H
#define PB_DEFAULT_CONF "/etc/patchbay/patchbay.conf"
#define PB_DEFAULT_DB "/etc/patchbay/patchbay.db"
#define PB_RUN_DIR "/run/patchbay"
enum pb_role { ROLE_CLIENT, ROLE_TARGET };
struct pb_config {
enum pb_role role;
char db_path[256];
char run_dir[256];
int log_level;
// client role
char target_host[256];
int target_port;
char identity_file[256]; // empty = auto (root key, then host key)
char identity_pass[256];
char known_hosts[256];
char ssh_user[64];
int services_interval; // seconds between Services reports
// target role
int ssh_port; // dedicated sshd port (same value clients use as TargetPort)
int hub_port; // loopback port data channels are forwarded to
char sshd_host_key[256];
char authorized_keys[256];
char daemon_path[256]; // forced command written into authorized_keys
};
void config_defaults(struct pb_config *c);
// Returns 0 on success, -1 if the file cannot be read; unknown keys are ignored
// because the web frontend shares the file.
int config_load(struct pb_config *c, const char *path);
int config_parse_line(struct pb_config *c, char *line);
#endif

54
backend/src/db.c Normal file
View File

@@ -0,0 +1,54 @@
#include <stdlib.h>
#include <sys/stat.h>
#include "db.h"
#include "log.h"
#include "schema.h" // generated from schema.sql by the Makefile
int db_exec(sqlite3 *db, const char *sql)
{
char *err = NULL;
if (sqlite3_exec(db, sql, NULL, NULL, &err) != SQLITE_OK) {
log_err("sqlite: %s", err ? err : "unknown error");
sqlite3_free(err);
return -1;
}
return 0;
}
sqlite3 *db_open(const char *path)
{
sqlite3 *db;
mode_t old = umask(0077); // the DB holds password hashes
int rc = sqlite3_open(path, &db);
umask(old);
if (rc != SQLITE_OK) {
log_err("cannot open database %s: %s", path, sqlite3_errmsg(db));
sqlite3_close(db);
return NULL;
}
sqlite3_busy_timeout(db, 5000);
if (db_exec(db, pb_schema_sql) < 0 || db_exec(db, "PRAGMA foreign_keys = ON;") < 0) {
sqlite3_close(db);
return NULL;
}
// Migration for databases from before tunnel nodes; fails harmlessly if present.
sqlite3_exec(db, "ALTER TABLE nodes ADD COLUMN iface TEXT NOT NULL DEFAULT ''", NULL, NULL, NULL);
return db;
}
long db_setting_int(sqlite3 *db, const char *key, long def)
{
sqlite3_stmt *st;
long v = def;
if (sqlite3_prepare_v2(db, "SELECT value FROM settings WHERE key = ?", -1, &st, NULL) != SQLITE_OK)
return def;
sqlite3_bind_text(st, 1, key, -1, SQLITE_STATIC);
if (sqlite3_step(st) == SQLITE_ROW) {
const char *s = (const char *)sqlite3_column_text(st, 0);
if (s)
v = strtol(s, NULL, 10);
}
sqlite3_finalize(st);
return v;
}

13
backend/src/db.h Normal file
View File

@@ -0,0 +1,13 @@
#ifndef PB_DB_H
#define PB_DB_H
#include <sqlite3.h>
// Opens (and creates/migrates) the database with WAL and a busy timeout so the
// daemon and the web frontend can write concurrently.
sqlite3 *db_open(const char *path);
int db_exec(sqlite3 *db, const char *sql);
// Integer setting with fallback.
long db_setting_int(sqlite3 *db, const char *key, long def);
#endif

1724
backend/src/hub.c Normal file

File diff suppressed because it is too large Load Diff

14
backend/src/hub.h Normal file
View File

@@ -0,0 +1,14 @@
#ifndef PB_HUB_H
#define PB_HUB_H
#include <sqlite3.h>
#include "config.h"
// Runs the target role until SIGTERM/SIGINT. SIGHUP reloads the patch graph.
int hub_run(const struct pb_config *cfg);
// Rewrites the sshd authorized_keys file from the clients table.
int hub_write_authorized_keys(const struct pb_config *cfg, sqlite3 *db);
#endif

31
backend/src/json.c Normal file
View File

@@ -0,0 +1,31 @@
#include <stdio.h>
#include "json.h"
int json_str(struct buf *b, const char *s)
{
if (buf_append(b, "\"", 1) < 0)
return -1;
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
char esc[8];
const char *out = NULL;
size_t n = 0;
switch (c) {
case '"': out = "\\\""; n = 2; break;
case '\\': out = "\\\\"; n = 2; break;
case '\n': out = "\\n"; n = 2; break;
case '\r': out = "\\r"; n = 2; break;
case '\t': out = "\\t"; n = 2; break;
default:
if (c < 0x20) {
snprintf(esc, sizeof(esc), "\\u%04x", c);
out = esc;
n = 6;
}
}
if (out ? buf_append(b, out, n) : buf_append(b, s, 1))
return -1;
}
return buf_append(b, "\"", 1);
}

9
backend/src/json.h Normal file
View File

@@ -0,0 +1,9 @@
#ifndef PB_JSON_H
#define PB_JSON_H
#include "proto.h"
// Appends s as a quoted, escaped JSON string.
int json_str(struct buf *b, const char *s);
#endif

33
backend/src/log.c Normal file
View File

@@ -0,0 +1,33 @@
#include <stdarg.h>
#include <stdio.h>
#include <time.h>
#include "log.h"
static int cur_level = LOG_LVL_INFO;
static const char *names[] = { "error", "warn", "info", "debug" };
void log_set_level(int level)
{
cur_level = level;
}
void log_msg(int level, const char *fmt, ...)
{
if (level > cur_level)
return;
// Service managers add their own timestamps, but plain foreground runs do not.
char ts[32];
time_t now = time(NULL);
struct tm tm;
localtime_r(&now, &tm);
strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm);
va_list ap;
va_start(ap, fmt);
fprintf(stderr, "%s patchbayd[%s]: ", ts, names[level]);
vfprintf(stderr, fmt, ap);
fputc('\n', stderr);
va_end(ap);
}

14
backend/src/log.h Normal file
View File

@@ -0,0 +1,14 @@
#ifndef PB_LOG_H
#define PB_LOG_H
enum { LOG_LVL_ERR, LOG_LVL_WARN, LOG_LVL_INFO, LOG_LVL_DEBUG };
void log_set_level(int level);
void log_msg(int level, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
#define log_err(...) log_msg(LOG_LVL_ERR, __VA_ARGS__)
#define log_warn(...) log_msg(LOG_LVL_WARN, __VA_ARGS__)
#define log_info(...) log_msg(LOG_LVL_INFO, __VA_ARGS__)
#define log_debug(...) log_msg(LOG_LVL_DEBUG, __VA_ARGS__)
#endif

120
backend/src/main.c Normal file
View File

@@ -0,0 +1,120 @@
#include <getopt.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "client.h"
#include "config.h"
#include "db.h"
#include "hub.h"
#include "log.h"
#include "proto.h"
#include "relay.h"
static void usage(void)
{
fprintf(stderr,
"usage: patchbayd [-c config] [-v] run in the role set by Role=\n"
" patchbayd --relay <client_id> forced command used by the PatchBay sshd\n"
" patchbayd --pubkey print this client's public key\n"
" patchbayd --write-keys regenerate authorized_keys (target)\n"
" patchbayd --print-config print effective settings as KEY=value\n"
" patchbayd --version\n");
}
static int print_pubkey(const struct pb_config *cfg)
{
char ident[256], pub[300];
if (client_identity(cfg, ident, sizeof(ident)) < 0) {
fprintf(stderr, "no SSH identity found\n");
return 1;
}
snprintf(pub, sizeof(pub), "%s.pub", ident);
FILE *f = fopen(pub, "r");
if (!f) {
fprintf(stderr, "cannot read %s; create it with: ssh-keygen -y -f %s > %s\n", pub, ident, pub);
return 1;
}
char line[8192];
while (fgets(line, sizeof(line), f))
fputs(line, stdout);
fclose(f);
return 0;
}
static void print_config(const struct pb_config *cfg)
{
printf("Role=%s\n", cfg->role == ROLE_TARGET ? "target" : "client");
printf("Database=%s\n", cfg->db_path);
printf("RunDir=%s\n", cfg->run_dir);
printf("TargetPort=%d\n", cfg->target_port);
printf("HubPort=%d\n", cfg->hub_port);
printf("SSHUser=%s\n", cfg->ssh_user);
printf("SSHHostKey=%s\n", cfg->sshd_host_key);
printf("AuthorizedKeys=%s\n", cfg->authorized_keys);
printf("DaemonPath=%s\n", cfg->daemon_path);
}
int main(int argc, char **argv)
{
const char *conf_path = PB_DEFAULT_CONF;
const char *relay_id = NULL;
int verbose = 0, mode = 0;
enum { M_RUN, M_RELAY, M_PUBKEY, M_KEYS, M_PRINT };
static const struct option opts[] = {
{ "config", required_argument, NULL, 'c' },
{ "relay", required_argument, NULL, 'r' },
{ "pubkey", no_argument, NULL, 'p' },
{ "write-keys", no_argument, NULL, 'k' },
{ "print-config", no_argument, NULL, 'P' },
{ "verbose", no_argument, NULL, 'v' },
{ "version", no_argument, NULL, 'V' },
{ "help", no_argument, NULL, 'h' },
{ NULL, 0, NULL, 0 },
};
int o;
while ((o = getopt_long(argc, argv, "c:vh", opts, NULL)) != -1) {
switch (o) {
case 'c': conf_path = optarg; break;
case 'r': mode = M_RELAY; relay_id = optarg; break;
case 'p': mode = M_PUBKEY; break;
case 'k': mode = M_KEYS; break;
case 'P': mode = M_PRINT; break;
case 'v': verbose = 1; break;
case 'V': printf("patchbayd %s\n", PB_VERSION); return 0;
default: usage(); return o == 'h' ? 0 : 2;
}
}
struct pb_config cfg;
config_defaults(&cfg);
// The relay runs as the unprivileged SSH user and may not be able to read
// the root-only config; defaults are enough for it.
if (config_load(&cfg, conf_path) < 0 && mode != M_RELAY) {
fprintf(stderr, "cannot read %s\n", conf_path);
return 1;
}
log_set_level(verbose ? LOG_LVL_DEBUG : cfg.log_level);
switch (mode) {
case M_RELAY:
return relay_run(&cfg, relay_id);
case M_PUBKEY:
return print_pubkey(&cfg);
case M_PRINT:
print_config(&cfg);
return 0;
case M_KEYS: {
sqlite3 *db = db_open(cfg.db_path);
if (!db)
return 1;
int rc = hub_write_authorized_keys(&cfg, db) < 0 ? 1 : 0;
sqlite3_close(db);
return rc;
}
}
log_info("patchbayd %s starting as %s", PB_VERSION, cfg.role == ROLE_TARGET ? "target" : "client");
return cfg.role == ROLE_TARGET ? hub_run(&cfg) : client_run(&cfg);
}

216
backend/src/net.c Normal file
View File

@@ -0,0 +1,216 @@
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <net/if.h>
#include <netinet/tcp.h>
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
#include "net.h"
int proto_parse(const char *s)
{
if (!strcasecmp(s, "tcp"))
return PROTO_TCP;
if (!strcasecmp(s, "udp"))
return PROTO_UDP;
return -1;
}
const char *proto_name(int proto)
{
return proto == PROTO_UDP ? "udp" : "tcp";
}
int set_nonblock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
return -1;
return fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
void tune_stream(int fd)
{
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &one, sizeof(one));
}
static int resolve(const char *host, int port, int proto, int passive, struct addrinfo **res)
{
struct addrinfo hints;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = proto == PROTO_UDP ? SOCK_DGRAM : SOCK_STREAM;
hints.ai_flags = passive ? AI_PASSIVE : 0;
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
if (host && (!*host || !strcmp(host, "*")))
host = NULL;
int rc = getaddrinfo(host, portstr, &hints, res);
if (rc != 0) {
errno = rc == EAI_SYSTEM ? errno : EADDRNOTAVAIL;
return -1;
}
return 0;
}
int iface_valid(const char *iface)
{
size_t n = strlen(iface);
if (n == 0 || n >= IFNAMSIZ)
return 0;
for (const char *p = iface; *p; p++)
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
*p == '_' || *p == '.' || *p == '-'))
return 0;
return 1;
}
static int bind_device(int fd, const char *iface)
{
if (!iface || !*iface || !strcmp(iface, "-"))
return 0;
return setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, iface, (socklen_t)strlen(iface));
}
int net_listen(const char *addr, int port, int proto, const char *iface)
{
struct addrinfo *res;
if (resolve(addr, port, proto, 1, &res) < 0)
return -1;
int fd = -1, err = 0;
for (struct addrinfo *ai = res; ai; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (fd < 0) {
err = errno;
continue;
}
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (bind_device(fd, iface) == 0 && bind(fd, ai->ai_addr, ai->ai_addrlen) == 0 &&
(proto == PROTO_UDP || listen(fd, 256) == 0))
break;
err = errno;
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd < 0)
errno = err;
return fd;
}
int net_connect(const char *host, int port, int proto, const char *iface, int *in_progress)
{
struct addrinfo *res;
*in_progress = 0;
if (resolve(host, port, proto, 0, &res) < 0)
return -1;
int fd = -1, err = 0;
for (struct addrinfo *ai = res; ai; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (fd < 0) {
err = errno;
continue;
}
if (bind_device(fd, iface) < 0) {
err = errno;
close(fd);
fd = -1;
continue;
}
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0)
break;
if (errno == EINPROGRESS) {
*in_progress = 1;
break;
}
err = errno;
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd < 0)
errno = err;
else if (proto == PROTO_TCP)
tune_stream(fd);
return fd;
}
int net_listen_unix(const char *path, int mode)
{
struct sockaddr_un sa;
if (strlen(path) >= sizeof(sa.sun_path)) {
errno = ENAMETOOLONG;
return -1;
}
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (fd < 0)
return -1;
memset(&sa, 0, sizeof(sa));
sa.sun_family = AF_UNIX;
strcpy(sa.sun_path, path);
unlink(path);
// umask covers the window between bind and chmod.
mode_t old = umask(0177);
int rc = bind(fd, (struct sockaddr *)&sa, sizeof(sa));
umask(old);
if (rc < 0 || chmod(path, (mode_t)mode) < 0 || listen(fd, 64) < 0) {
int e = errno;
close(fd);
errno = e;
return -1;
}
return fd;
}
int net_connect_unix(const char *path)
{
struct sockaddr_un sa;
if (strlen(path) >= sizeof(sa.sun_path)) {
errno = ENAMETOOLONG;
return -1;
}
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (fd < 0)
return -1;
memset(&sa, 0, sizeof(sa));
sa.sun_family = AF_UNIX;
strcpy(sa.sun_path, path);
if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
int e = errno;
close(fd);
errno = e;
return -1;
}
return fd;
}
void sockaddr_str(const struct sockaddr *sa, char *out, size_t outsz)
{
char host[INET6_ADDRSTRLEN] = "?";
int port = 0;
if (sa->sa_family == AF_INET) {
const struct sockaddr_in *s4 = (const struct sockaddr_in *)sa;
inet_ntop(AF_INET, &s4->sin_addr, host, sizeof(host));
port = ntohs(s4->sin_port);
snprintf(out, outsz, "%s:%d", host, port);
} else if (sa->sa_family == AF_INET6) {
const struct sockaddr_in6 *s6 = (const struct sockaddr_in6 *)sa;
inet_ntop(AF_INET6, &s6->sin6_addr, host, sizeof(host));
port = ntohs(s6->sin6_port);
snprintf(out, outsz, "[%s]:%d", host, port);
} else {
snprintf(out, outsz, "?");
}
}

29
backend/src/net.h Normal file
View File

@@ -0,0 +1,29 @@
#ifndef PB_NET_H
#define PB_NET_H
#include <sys/socket.h>
enum pb_proto { PROTO_TCP, PROTO_UDP };
int proto_parse(const char *s); // -1 on unknown
const char *proto_name(int proto);
int set_nonblock(int fd);
void tune_stream(int fd);
// Binds a listening (TCP) or bound (UDP) socket. iface (NULL/"" = any) pins the
// socket to one interface with SO_BINDTODEVICE. Returns fd or -1 with errno.
int net_listen(const char *addr, int port, int proto, const char *iface);
// Starts a non-blocking connect, optionally pinned to iface. Returns fd or -1;
// *in_progress set if pending.
int net_connect(const char *host, int port, int proto, const char *iface, int *in_progress);
int net_listen_unix(const char *path, int mode);
int net_connect_unix(const char *path);
// 1 if iface is a plausible interface name (also safe as a protocol field).
int iface_valid(const char *iface);
// Formats a sockaddr as "addr:port" (IPv6 in brackets).
void sockaddr_str(const struct sockaddr *sa, char *out, size_t outsz);
#endif

152
backend/src/proto.c Normal file
View File

@@ -0,0 +1,152 @@
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/random.h>
#include "proto.h"
/* Byte buffer */
void buf_init(struct buf *b)
{
b->data = NULL;
b->len = 0;
b->cap = 0;
}
void buf_free(struct buf *b)
{
free(b->data);
buf_init(b);
}
static int buf_reserve(struct buf *b, size_t extra)
{
if (b->len + extra <= b->cap)
return 0;
size_t cap = b->cap ? b->cap : 4096;
while (cap < b->len + extra)
cap *= 2;
char *p = realloc(b->data, cap);
if (!p)
return -1;
b->data = p;
b->cap = cap;
return 0;
}
int buf_append(struct buf *b, const void *p, size_t n)
{
if (buf_reserve(b, n) < 0)
return -1;
memcpy(b->data + b->len, p, n);
b->len += n;
return 0;
}
void buf_consume(struct buf *b, size_t n)
{
if (n >= b->len) {
b->len = 0;
return;
}
memmove(b->data, b->data + n, b->len - n);
b->len -= n;
}
int buf_printf(struct buf *b, const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(NULL, 0, fmt, ap);
va_end(ap);
if (n < 0 || buf_reserve(b, (size_t)n + 1) < 0)
return -1;
va_start(ap, fmt);
vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap);
va_end(ap);
b->len += (size_t)n;
return n;
}
/* Line protocol helpers */
int line_next(struct buf *b, char *out, size_t outsz)
{
char *nl = b->len ? memchr(b->data, '\n', b->len) : NULL;
if (!nl)
return b->len >= PB_MAX_LINE ? -1 : 0;
size_t n = (size_t)(nl - b->data);
if (n >= outsz || n >= PB_MAX_LINE)
return -1;
memcpy(out, b->data, n);
if (n > 0 && out[n - 1] == '\r')
n--;
out[n] = '\0';
buf_consume(b, (size_t)(nl - b->data) + 1);
return 1;
}
int line_split(char *line, char **fields, int max)
{
int n = 0;
char *save = NULL;
for (char *t = strtok_r(line, " ", &save); t && n < max; t = strtok_r(NULL, " ", &save))
fields[n++] = t;
return n;
}
void field_sanitise(char *s)
{
if (!*s) {
// Empty fields would shift the field count on the other side.
s[0] = '-';
s[1] = '\0';
return;
}
for (; *s; s++) {
unsigned char ch = (unsigned char)*s;
if (ch <= ' ' || ch >= 127)
*s = '_';
}
}
/* UDP framing */
int udp_frame_append(struct buf *b, const void *payload, size_t n)
{
if (n > 0xffff)
return -1;
unsigned char hdr[2] = { (unsigned char)(n >> 8), (unsigned char)(n & 0xff) };
if (buf_append(b, hdr, 2) < 0 || buf_append(b, payload, n) < 0)
return -1;
return 0;
}
int udp_frame_peek(const struct buf *b, const char **payload)
{
if (b->len < 2)
return 0;
const unsigned char *p = (const unsigned char *)b->data;
size_t n = ((size_t)p[0] << 8) | p[1];
if (b->len < 2 + n)
return 0;
*payload = b->data + 2;
// A zero-length datagram is valid; signal it with -2 to keep 0 = incomplete.
return n ? (int)n : -2;
}
int random_token(char *out, size_t outsz)
{
unsigned char raw[PB_TOKEN_LEN / 2];
if (outsz < PB_TOKEN_LEN + 1)
return -1;
if (getrandom(raw, sizeof(raw), 0) != (ssize_t)sizeof(raw))
return -1;
for (size_t i = 0; i < sizeof(raw); i++)
sprintf(out + i * 2, "%02x", raw[i]);
out[PB_TOKEN_LEN] = '\0';
return 0;
}

73
backend/src/proto.h Normal file
View File

@@ -0,0 +1,73 @@
#ifndef PB_PROTO_H
#define PB_PROTO_H
#include <stddef.h>
#include <stdint.h>
/* Control protocol
*
* One text line per message, fields separated by single spaces, no field may
* contain whitespace. Carried over the SSH exec channel whose forced command
* (patchbayd --relay) bridges it to the hub socket on the target.
*
* hub -> client: HELLO <token> <hub_port>
* SINKS-BEGIN / SINK <sink_id> <proto> <bind> <port> <iface|-> / SINKS-END
* OPEN <conn_id> <proto> <host> <port> <iface|->
* SVC-REQ, PING
* client -> hub: HELLO <version> <hostname>
* SVC-BEGIN / SVC <proto> <addr> <port> <pid> <process> / SVC-END
* IF-BEGIN / IF <name> <ipv4/prefix|-> / IF-END (tun interfaces)
* SINKSTATE <sink_id> <ok|err> <reason>
* PONG
*
* <iface> pins a socket to an interface (tunnel nodes, SO_BINDTODEVICE).
*
* Data channels are direct-tcpip channels to 127.0.0.1:HubPort on the target.
* They start with "PB1 <token> SINK <sink_id>\n" (a client sink accepted a
* connection) or "PB1 <token> OPEN <conn_id>\n" (answer to OPEN), followed by
* raw stream bytes. UDP flows use length-prefixed frames, see udp_frame_*.
*/
#define PB_VERSION "0.1.0"
#define PB_TOKEN_LEN 32 // hex chars
#define PB_MAX_LINE 1024
#define PB_MAX_FIELDS 8
#define PB_UDP_MAX 65507
/* Byte buffer */
struct buf {
char *data;
size_t len;
size_t cap;
};
void buf_init(struct buf *b);
void buf_free(struct buf *b);
int buf_append(struct buf *b, const void *p, size_t n);
void buf_consume(struct buf *b, size_t n);
int buf_printf(struct buf *b, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
/* Line protocol helpers */
// Extracts the next complete line (without '\n', '\r' stripped) from b into
// out. Returns 1 if a line was extracted, 0 if incomplete, -1 if a line exceeds
// PB_MAX_LINE.
int line_next(struct buf *b, char *out, size_t outsz);
// Splits a line in place on spaces. Returns number of fields.
int line_split(char *line, char **fields, int max);
// Replaces characters that are not safe for a protocol field with '_'.
void field_sanitise(char *s);
/* UDP framing: 2 byte big endian length + payload */
int udp_frame_append(struct buf *b, const void *payload, size_t n);
// Returns payload length and sets *payload if a full frame is at the start of
// b->data, 0 if incomplete. Caller consumes 2 + length bytes afterwards.
int udp_frame_peek(const struct buf *b, const char **payload);
int random_token(char *out, size_t outsz);
#endif

66
backend/src/relay.c Normal file
View File

@@ -0,0 +1,66 @@
#include <errno.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "log.h"
#include "net.h"
#include "relay.h"
// Copies whatever is readable from one fd to the other. 0 on EOF/error.
static int copy_once(int from, int to)
{
char buf[16384];
ssize_t n = read(from, buf, sizeof(buf));
if (n <= 0)
return 0;
for (ssize_t off = 0; off < n;) {
ssize_t w = write(to, buf + off, (size_t)(n - off));
if (w < 0) {
if (errno == EINTR)
continue;
return 0;
}
off += w;
}
return 1;
}
int relay_run(const struct pb_config *cfg, const char *client_id)
{
char path[300];
snprintf(path, sizeof(path), "%s/hub.sock", cfg->run_dir);
int fd = net_connect_unix(path);
if (fd < 0) {
log_err("relay: cannot reach hub at %s: %s", path, strerror(errno));
return 1;
}
// sshd sets SSH_CONNECTION="client_ip client_port server_ip server_port".
char addr[64] = "-";
const char *conn = getenv("SSH_CONNECTION");
if (conn && sscanf(conn, "%63s", addr) != 1)
strcpy(addr, "-");
char hello[128];
int n = snprintf(hello, sizeof(hello), "RELAY %d %s\n", atoi(client_id), addr);
if (write(fd, hello, (size_t)n) != n)
return 1;
struct pollfd p[2] = { { .fd = 0, .events = POLLIN }, { .fd = fd, .events = POLLIN } };
for (;;) {
if (poll(p, 2, -1) < 0) {
if (errno == EINTR)
continue;
break;
}
if (p[0].revents && !copy_once(0, fd))
break;
if (p[1].revents && !copy_once(fd, 1))
break;
}
close(fd);
return 0;
}

10
backend/src/relay.h Normal file
View File

@@ -0,0 +1,10 @@
#ifndef PB_RELAY_H
#define PB_RELAY_H
#include "config.h"
// Forced command of every client key in authorized_keys: bridges the SSH exec
// channel (stdin/stdout) to the hub socket, announcing the client id.
int relay_run(const struct pb_config *cfg, const char *client_id);
#endif

213
backend/src/services.c Normal file
View File

@@ -0,0 +1,213 @@
#include <arpa/inet.h>
#include <ctype.h>
#include <dirent.h>
#include <ifaddrs.h>
#include <net/if.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "net.h"
#include "services.h"
#define TCP_LISTEN 0x0A
#define TCP_CLOSE 0x07
int services_parse_line(const char *line, int v6, int udp, struct svc_entry *e)
{
char local[64], remote[64];
unsigned int state;
unsigned long inode;
// sl local rem st tx:rx tr:when retrnsmt uid timeout inode
if (sscanf(line, " %*d: %63s %63s %x %*s %*s %*s %*u %*u %lu",
local, remote, &state, &inode) != 4)
return -1;
char *lport = strchr(local, ':');
char *rport = strchr(remote, ':');
if (!lport || !rport)
return -1;
*lport++ = '\0';
if (udp) {
// Unconnected UDP sockets have no remote port and sit in TCP_CLOSE.
if (state != TCP_CLOSE || strtoul(rport + 1, NULL, 16) != 0)
return 0;
} else if (state != TCP_LISTEN) {
return 0;
}
memset(e, 0, sizeof(*e));
e->proto = udp ? PROTO_UDP : PROTO_TCP;
e->port = (int)strtoul(lport, NULL, 16);
e->inode = inode;
// The kernel prints the raw 32 bit words, so copying them back in host
// order restores the network byte order address.
if (v6) {
struct in6_addr a;
if (strlen(local) != 32)
return -1;
for (int i = 0; i < 4; i++) {
char word[9];
memcpy(word, local + i * 8, 8);
word[8] = '\0';
unsigned int w = (unsigned int)strtoul(word, NULL, 16);
memcpy((char *)&a + i * 4, &w, 4);
}
inet_ntop(AF_INET6, &a, e->addr, sizeof(e->addr));
} else {
struct in_addr a;
unsigned int w = (unsigned int)strtoul(local, NULL, 16);
memcpy(&a, &w, 4);
inet_ntop(AF_INET, &a, e->addr, sizeof(e->addr));
}
return 1;
}
static int scan_file(const char *path, int v6, int udp, struct svc_entry **arr, int *n, int *cap)
{
FILE *f = fopen(path, "r");
if (!f)
return 0; // e.g. no IPv6
char line[512];
if (!fgets(line, sizeof(line), f)) { // header
fclose(f);
return 0;
}
while (fgets(line, sizeof(line), f)) {
struct svc_entry e;
if (services_parse_line(line, v6, udp, &e) != 1)
continue;
if (*n == *cap) {
int nc = *cap ? *cap * 2 : 64;
struct svc_entry *p = realloc(*arr, (size_t)nc * sizeof(*p));
if (!p) {
fclose(f);
return -1;
}
*arr = p;
*cap = nc;
}
(*arr)[(*n)++] = e;
}
fclose(f);
return 0;
}
static void map_pids(struct svc_entry *arr, int n)
{
DIR *proc = opendir("/proc");
if (!proc)
return;
struct dirent *de;
while ((de = readdir(proc))) {
if (!isdigit((unsigned char)de->d_name[0]))
continue;
int pid = atoi(de->d_name);
char fddir[64];
snprintf(fddir, sizeof(fddir), "/proc/%d/fd", pid);
DIR *fds = opendir(fddir);
if (!fds)
continue;
struct dirent *fe;
while ((fe = readdir(fds))) {
char lpath[320], target[64];
snprintf(lpath, sizeof(lpath), "%s/%s", fddir, fe->d_name);
ssize_t l = readlink(lpath, target, sizeof(target) - 1);
if (l <= 0)
continue;
target[l] = '\0';
unsigned long inode;
if (sscanf(target, "socket:[%lu]", &inode) != 1)
continue;
for (int i = 0; i < n; i++) {
if (arr[i].inode != inode || arr[i].pid)
continue;
arr[i].pid = pid;
char cpath[64];
snprintf(cpath, sizeof(cpath), "/proc/%d/comm", pid);
FILE *cf = fopen(cpath, "r");
if (cf) {
if (fgets(arr[i].process, sizeof(arr[i].process), cf))
arr[i].process[strcspn(arr[i].process, "\n")] = '\0';
fclose(cf);
}
}
}
closedir(fds);
}
closedir(proc);
}
int services_scan(struct svc_entry **out)
{
struct svc_entry *arr = NULL;
int n = 0, cap = 0;
if (scan_file("/proc/net/tcp", 0, 0, &arr, &n, &cap) < 0 ||
scan_file("/proc/net/tcp6", 1, 0, &arr, &n, &cap) < 0 ||
scan_file("/proc/net/udp", 0, 1, &arr, &n, &cap) < 0 ||
scan_file("/proc/net/udp6", 1, 1, &arr, &n, &cap) < 0) {
free(arr);
return -1;
}
map_pids(arr, n);
// SO_REUSEPORT and multiple workers produce duplicates; keep the first.
int m = 0;
for (int i = 0; i < n; i++) {
int dup = 0;
for (int j = 0; j < m && !dup; j++)
dup = arr[j].proto == arr[i].proto && arr[j].port == arr[i].port &&
!strcmp(arr[j].addr, arr[i].addr);
if (!dup)
arr[m++] = arr[i];
}
for (int i = 0; i < m; i++)
if (!arr[i].process[0])
strcpy(arr[i].process, "-");
*out = arr;
return m;
}
int ifaces_scan(struct iface_entry **out)
{
struct ifaddrs *ifa;
if (getifaddrs(&ifa) < 0)
return -1;
struct iface_entry *arr = NULL;
int n = 0, cap = 0;
for (struct ifaddrs *i = ifa; i; i = i->ifa_next) {
if (strncmp(i->ifa_name, "tun", 3))
continue;
int k;
for (k = 0; k < n && strcmp(arr[k].name, i->ifa_name); k++)
;
if (k == n) {
if (n == cap) {
cap = cap ? cap * 2 : 8;
struct iface_entry *p = realloc(arr, (size_t)cap * sizeof(*p));
if (!p)
break;
arr = p;
}
snprintf(arr[n].name, sizeof(arr[n].name), "%s", i->ifa_name);
strcpy(arr[n].addr, "-");
n++;
}
// First IPv4 address wins; interfaces without one keep "-".
if (i->ifa_addr && i->ifa_addr->sa_family == AF_INET && !strcmp(arr[k].addr, "-")) {
char a[INET_ADDRSTRLEN];
int prefix = 0;
inet_ntop(AF_INET, &((struct sockaddr_in *)i->ifa_addr)->sin_addr, a, sizeof(a));
if (i->ifa_netmask)
prefix = __builtin_popcount(((struct sockaddr_in *)i->ifa_netmask)->sin_addr.s_addr);
snprintf(arr[k].addr, sizeof(arr[k].addr), "%s/%d", a, prefix);
}
}
freeifaddrs(ifa);
*out = arr;
return n;
}

30
backend/src/services.h Normal file
View File

@@ -0,0 +1,30 @@
#ifndef PB_SERVICES_H
#define PB_SERVICES_H
struct svc_entry {
int proto;
char addr[64];
int port;
int pid; // 0 if unknown
char process[32];
unsigned long inode;
};
// Collects listening TCP and bound, unconnected UDP sockets with their owning
// processes from /proc. Returns count (>= 0) and a malloc'd array in *out.
int services_scan(struct svc_entry **out);
// Parses one data line of /proc/net/{tcp,udp}[6]. Returns 1 if it describes a
// listening socket, 0 if not, -1 on parse error.
int services_parse_line(const char *line, int v6, int udp, struct svc_entry *e);
struct iface_entry {
char name[16];
char addr[64]; // "a.b.c.d/prefix" or "-" without IPv4
};
// Lists tun* interfaces with their IPv4 addresses (one entry per interface
// without an address). Returns count and a malloc'd array in *out.
int ifaces_scan(struct iface_entry **out);
#endif

141
backend/src/stats.c Normal file
View File

@@ -0,0 +1,141 @@
#include <stdlib.h>
#include <string.h>
#include "db.h"
#include "log.h"
#include "stats.h"
// Entries are allocated individually and never freed, so connections can keep
// pointers to them across reloads.
static struct sink_stats **all;
static int nall, capall;
struct sink_stats *stats_get(int sink_id)
{
for (int i = 0; i < nall; i++)
if (all[i]->sink_id == sink_id)
return all[i];
if (nall == capall) {
int nc = capall ? capall * 2 : 32;
struct sink_stats **p = realloc(all, (size_t)nc * sizeof(*p));
if (!p)
return NULL;
all = p;
capall = nc;
}
struct sink_stats *s = calloc(1, sizeof(*s));
if (!s)
return NULL;
s->sink_id = sink_id;
all[nall++] = s;
return s;
}
void stats_add(struct sink_stats *s, uint64_t in, uint64_t out)
{
s->sec_in += in;
s->sec_out += out;
s->min_in += in;
s->min_out += out;
s->total_in += in;
s->total_out += out;
}
void stats_conn_open(struct sink_stats *s)
{
s->active++;
s->min_conns++;
}
void stats_conn_close(struct sink_stats *s)
{
if (s->active > 0)
s->active--;
}
void stats_tick_second(double dt)
{
if (dt <= 0)
dt = 1;
for (int i = 0; i < nall; i++) {
all[i]->rate_in = (double)all[i]->sec_in / dt;
all[i]->rate_out = (double)all[i]->sec_out / dt;
all[i]->sec_in = all[i]->sec_out = 0;
}
}
static void upsert(sqlite3_stmt *st, int sink, const char *tier, long ts,
uint64_t in, uint64_t out, uint32_t conns)
{
sqlite3_reset(st);
sqlite3_bind_int(st, 1, sink);
sqlite3_bind_text(st, 2, tier, -1, SQLITE_STATIC);
sqlite3_bind_int64(st, 3, ts);
sqlite3_bind_int64(st, 4, (sqlite3_int64)in);
sqlite3_bind_int64(st, 5, (sqlite3_int64)out);
sqlite3_bind_int(st, 6, (int)conns);
if (sqlite3_step(st) != SQLITE_DONE)
log_warn("stats insert failed");
}
static void prune(sqlite3 *db, const char *tier, long cutoff)
{
sqlite3_stmt *st;
if (sqlite3_prepare_v2(db, "DELETE FROM stats WHERE tier = ? AND ts < ?", -1, &st, NULL) != SQLITE_OK)
return;
sqlite3_bind_text(st, 1, tier, -1, SQLITE_STATIC);
sqlite3_bind_int64(st, 2, cutoff);
sqlite3_step(st);
sqlite3_finalize(st);
}
void stats_flush(sqlite3 *db, long now)
{
long minute = now - now % 60, hour = now - now % 3600, day = now - now % 86400;
sqlite3_stmt *st;
const char *sql =
"INSERT INTO stats (sink_id, tier, ts, bytes_in, bytes_out, conns) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT (sink_id, tier, ts) DO UPDATE SET "
"bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out, "
"conns = conns + excluded.conns";
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
log_err("stats: %s", sqlite3_errmsg(db));
return;
}
db_exec(db, "BEGIN");
for (int i = 0; i < nall; i++) {
struct sink_stats *s = all[i];
if (!s->min_in && !s->min_out && !s->min_conns)
continue;
upsert(st, s->sink_id, "m", minute, s->min_in, s->min_out, s->min_conns);
upsert(st, s->sink_id, "h", hour, s->min_in, s->min_out, s->min_conns);
upsert(st, s->sink_id, "d", day, s->min_in, s->min_out, s->min_conns);
s->min_in = s->min_out = 0;
s->min_conns = 0;
}
sqlite3_finalize(st);
long mh = db_setting_int(db, "stats_minute_hours", 48);
long hd = db_setting_int(db, "stats_hour_days", 90);
long dd = db_setting_int(db, "stats_day_days", 0);
if (mh > 0)
prune(db, "m", now - mh * 3600);
if (hd > 0)
prune(db, "h", now - hd * 86400);
if (dd > 0)
prune(db, "d", now - dd * 86400);
db_exec(db, "COMMIT");
}
int stats_live_json(struct buf *b)
{
buf_append(b, "{", 1);
for (int i = 0; i < nall; i++) {
struct sink_stats *s = all[i];
buf_printf(b, "%s\"%d\":{\"in\":%.0f,\"out\":%.0f,\"active\":%d,"
"\"total_in\":%llu,\"total_out\":%llu}",
i ? "," : "", s->sink_id, s->rate_in, s->rate_out, s->active,
(unsigned long long)s->total_in, (unsigned long long)s->total_out);
}
return buf_append(b, "}", 1);
}

33
backend/src/stats.h Normal file
View File

@@ -0,0 +1,33 @@
#ifndef PB_STATS_H
#define PB_STATS_H
#include <sqlite3.h>
#include <stdint.h>
#include "proto.h"
// Traffic counters per connection (= per sink node), kept on the target.
struct sink_stats {
int sink_id;
uint64_t sec_in, sec_out; // current second
uint64_t min_in, min_out; // current minute
uint32_t min_conns;
double rate_in, rate_out; // bytes/s over the last second
int active; // open connections
uint64_t total_in, total_out; // since daemon start
};
struct sink_stats *stats_get(int sink_id);
void stats_add(struct sink_stats *s, uint64_t in, uint64_t out);
void stats_conn_open(struct sink_stats *s);
void stats_conn_close(struct sink_stats *s);
void stats_tick_second(double dt);
// Writes the minute's counters (also rolled into hour and day rows) and
// prunes rows past their retention.
void stats_flush(sqlite3 *db, long now);
// {"<sink_id>": {"in": rate, "out": rate, "active": n, "total_in": .., ...}, ...}
int stats_live_json(struct buf *b);
#endif

145
backend/tests/test_main.c Normal file
View File

@@ -0,0 +1,145 @@
/* Minimal unit test runner: ./test_runner [test_name ...] */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../src/config.h"
#include "../src/json.h"
#include "../src/net.h"
#include "../src/proto.h"
#include "../src/services.h"
static int failures;
#define CHECK(cond) do { \
if (!(cond)) { \
fprintf(stderr, " %s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #cond); \
failures++; \
} \
} while (0)
/* Tests */
static void test_config(void)
{
struct pb_config c;
config_defaults(&c);
char l1[] = "Role = target";
char l2[] = " TargetPort=2200 # not a comment, atoi stops";
char l3[] = "# comment";
char l4[] = "IdentityFile = \" /etc/x \"";
char l5[] = "SysopPassword = secret";
char l6[] = "garbage";
CHECK(config_parse_line(&c, l1) == 0 && c.role == ROLE_TARGET);
CHECK(config_parse_line(&c, l2) == 0 && c.target_port == 2200);
CHECK(config_parse_line(&c, l3) == 0);
CHECK(config_parse_line(&c, l4) == 0 && !strcmp(c.identity_file, " /etc/x "));
CHECK(config_parse_line(&c, l5) == 0);
CHECK(config_parse_line(&c, l6) == -1);
}
static void test_lines(void)
{
struct buf b;
buf_init(&b);
char out[PB_MAX_LINE];
buf_append(&b, "HELLO abc 7701\r\nSINK 1 tcp", 26);
CHECK(line_next(&b, out, sizeof(out)) == 1 && !strcmp(out, "HELLO abc 7701"));
CHECK(line_next(&b, out, sizeof(out)) == 0);
buf_append(&b, " 0.0.0.0 80\n", 12);
CHECK(line_next(&b, out, sizeof(out)) == 1);
char *f[PB_MAX_FIELDS];
CHECK(line_split(out, f, PB_MAX_FIELDS) == 5 && !strcmp(f[4], "80"));
buf_free(&b);
char s1[16] = "a b\tc";
field_sanitise(s1);
CHECK(!strcmp(s1, "a_b_c"));
char s2[4] = "";
field_sanitise(s2);
CHECK(!strcmp(s2, "-"));
}
static void test_udp_frames(void)
{
struct buf b;
buf_init(&b);
const char *p;
CHECK(udp_frame_append(&b, "hello", 5) == 0);
CHECK(udp_frame_append(&b, "", 0) == 0);
CHECK(udp_frame_peek(&b, &p) == 5 && !memcmp(p, "hello", 5));
buf_consume(&b, 7);
CHECK(udp_frame_peek(&b, &p) == -2);
buf_consume(&b, 2);
CHECK(udp_frame_peek(&b, &p) == 0);
buf_append(&b, "\x00\x03" "ab", 4);
CHECK(udp_frame_peek(&b, &p) == 0); // incomplete
buf_free(&b);
}
static void test_services_parse(void)
{
struct svc_entry e;
// 127.0.0.1:22 LISTEN, as printed on a little endian machine.
const char *tcp = " 0: 0100007F:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0";
CHECK(services_parse_line(tcp, 0, 0, &e) == 1);
CHECK(e.port == 22 && e.inode == 12345 && e.proto == PROTO_TCP);
// Established connection is skipped.
const char *est = " 1: 0100007F:0016 0100007F:D431 01 00000000:00000000 00:00000000 00000000 0 0 222 1 0000000000000000 100 0 0 10 0";
CHECK(services_parse_line(est, 0, 0, &e) == 0);
// :: port 53 UDP
const char *udp6 = " 10: 00000000000000000000000000000000:0035 00000000000000000000000000000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 999 2 0000000000000000 0";
CHECK(services_parse_line(udp6, 1, 1, &e) == 1);
CHECK(e.port == 53 && !strcmp(e.addr, "::") && e.proto == PROTO_UDP);
CHECK(services_parse_line("bogus", 0, 0, &e) == -1);
}
static void test_json(void)
{
struct buf b;
buf_init(&b);
json_str(&b, "a\"b\\c\n\x01");
buf_append(&b, "", 1);
CHECK(!strcmp(b.data, "\"a\\\"b\\\\c\\n\\u0001\""));
buf_free(&b);
}
static void test_token(void)
{
char a[PB_TOKEN_LEN + 1], b[PB_TOKEN_LEN + 1];
CHECK(random_token(a, sizeof(a)) == 0 && strlen(a) == PB_TOKEN_LEN);
CHECK(random_token(b, sizeof(b)) == 0 && strcmp(a, b));
}
/* Runner */
static const struct { const char *name; void (*fn)(void); } tests[] = {
{ "config", test_config },
{ "lines", test_lines },
{ "udp_frames", test_udp_frames },
{ "services_parse", test_services_parse },
{ "json", test_json },
{ "token", test_token },
};
int main(int argc, char **argv)
{
int ran = 0;
for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) {
int want = argc < 2;
for (int a = 1; a < argc; a++)
want |= !strcmp(argv[a], tests[i].name);
if (!want)
continue;
int before = failures;
tests[i].fn();
printf("%s %s\n", failures == before ? "ok " : "FAIL", tests[i].name);
ran++;
}
if (!ran) {
fprintf(stderr, "no matching tests\n");
return 2;
}
return failures ? 1 : 0;
}

18
docker/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# PatchBay test image; the same image runs as target or client (see entrypoint.sh).
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential pkg-config libssh2-1-dev libsqlite3-dev \
openssh-server openssh-client python3 python3-flask python3-cryptography \
sqlite3 netcat-openbsd iproute2 procps socat curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY . /src
RUN make clean && make all test-c && make install \
&& useradd --system --home-dir /nonexistent --shell /bin/sh patchbay \
&& usermod -p '*' patchbay \
&& mkdir -p /etc/patchbay /run/sshd
COPY docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

45
docker/compose.yml Normal file
View File

@@ -0,0 +1,45 @@
# Integration test topology: one target (exit gateway) and two clients.
# Run via docker/run-tests.sh (or "make docker-test").
name: patchbay-test
x-common: &common
build:
context: ..
dockerfile: docker/Dockerfile
image: patchbay-test
volumes:
- shared:/shared
networks: [pbnet]
init: true
# Tunnel tests create veth pairs and network namespaces.
privileged: true
services:
target:
<<: *common
hostname: target
environment:
PB_ROLE: target
PB_MAILHOST: log
ports:
- "127.0.0.1:18443:8443" # web UI for manual checks
client1:
<<: *common
hostname: client1
environment:
PB_ROLE: client
depends_on: [target]
client2:
<<: *common
hostname: client2
environment:
PB_ROLE: client
depends_on: [target]
volumes:
shared:
networks:
pbnet:

65
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Test container entrypoint. PB_ROLE=target|client selects the role.
set -eu
ROLE=${PB_ROLE:-client}
CONF=/etc/patchbay/patchbay.conf
mkdir -p /etc/patchbay /shared
if [ "$ROLE" = target ]; then
if [ ! -f "$CONF" ]; then
cat > "$CONF" <<EOF
Role = target
TargetPort = 2222
HubPort = 7701
WebPort = 8443
SysopUser = admin
SysopEmail = ${PB_SYSOP_EMAIL:-admin@example.org}
SysopPassword = ${PB_SYSOP_PASSWORD:-patchbay-test-pw}
MailHost = ${PB_MAILHOST:-log}
EOF
chmod 600 "$CONF"
# Optional real SMTP account, mounted at runtime (INI [smtp] section).
if [ -f /run/secrets/smtp.conf ]; then
python3 - "$CONF" <<'PYEOF'
import configparser, sys
sys.path.insert(0, "/usr/local/lib/patchbay/web")
from patchbay_web.conf import Config
c = configparser.RawConfigParser()
c.read("/run/secrets/smtp.conf")
s = c["smtp"]
Config(sys.argv[1]).update({
"MailHost": s["host"], "MailPort": s["port"],
"MailSecurity": "ssl" if s.getboolean("ssl", fallback=False) else "starttls",
"MailUser": s["user"], "MailPassword": s["password"], "MailFrom": s.get("from", s["user"]),
})
PYEOF
fi
fi
patchbayd -c "$CONF" &
sleep 0.5
/usr/local/lib/patchbay/patchbay-sshd "$CONF" &
cd /usr/local/lib/patchbay/web
python3 -m patchbay_web -c "$CONF" serve &
else
if [ ! -f "$CONF" ]; then
cat > "$CONF" <<EOF
Role = client
TargetHost = ${PB_TARGET:-target}
TargetPort = 2222
IdentityFile = /etc/patchbay/id_ed25519
EOF
fi
[ -f /etc/patchbay/id_ed25519 ] || ssh-keygen -q -t ed25519 -N '' -C "$(hostname)" -f /etc/patchbay/id_ed25519
cp /etc/patchbay/id_ed25519.pub "/shared/$(hostname).pub"
# Test services: TCP and UDP echo.
socat TCP-LISTEN:9000,fork,reuseaddr EXEC:cat &
socat UDP-RECVFROM:9001,fork EXEC:cat &
# Discard sink for throughput tests.
socat -u TCP-LISTEN:9002,fork,reuseaddr OPEN:/dev/null &
patchbayd -c "$CONF" &
fi
# Exit when any service dies so the test notices.
wait -n

164
docker/run-tests.sh Executable file
View File

@@ -0,0 +1,164 @@
#!/bin/bash
# End-to-end test: builds the image, starts target + 2 clients, patches
# services through PatchBay and checks TCP, UDP, client-to-client, integrity,
# throughput, Services reporting and the web login.
# Usage: docker/run-tests.sh [--keep] (--keep leaves the containers running)
set -u
cd "$(dirname "$0")"
DC="docker compose -f compose.yml"
KEEP=0
[ "${1:-}" = "--keep" ] && KEEP=1
PASS=0
FAIL=0
ok() { echo "PASS $*"; PASS=$((PASS + 1)); }
bad() { echo "FAIL $*"; FAIL=$((FAIL + 1)); }
on() { local c=$1; shift; $DC exec -T "$c" bash -c "$*"; }
cleanup() {
if [ $KEEP = 0 ]; then
$DC down -v >/dev/null 2>&1
else
echo "containers kept; web UI at https://127.0.0.1:18443 (admin / patchbay-test-pw, code in 'docker compose -f docker/compose.yml logs target')"
fi
}
trap cleanup EXIT
$DC down -v >/dev/null 2>&1
$DC up -d --build || exit 1
echo "waiting for client keys..."
for i in $(seq 60); do
on target 'ls /shared/client1.pub /shared/client2.pub' >/dev/null 2>&1 && break
sleep 1
done
on target 'cd /usr/local/lib/patchbay/web &&
python3 -m patchbay_web add-client client1 < /shared/client1.pub &&
python3 -m patchbay_web add-client client2 < /shared/client2.pub' >/dev/null
# Patch: echo service on client1 exposed publicly (via splitter) and on
# client2's loopback, a UDP echo, and a discard sink for throughput.
on target "sqlite3 /etc/patchbay/patchbay.db \"
INSERT INTO nodes (id, type, client_id, host, port, proto) VALUES
(1, 'client_source', 1, '127.0.0.1', 9000, 'tcp'),
(2, 'public_sink', NULL, '0.0.0.0', 2200, 'tcp'),
(3, 'client_source', 1, '127.0.0.1', 9001, 'udp'),
(4, 'public_sink', NULL, '0.0.0.0', 2201, 'udp'),
(5, 'splitter', NULL, '', 0, 'tcp'),
(6, 'client_sink', 2, '127.0.0.1', 7000, 'tcp'),
(7, 'client_source', 1, '127.0.0.1', 9002, 'tcp'),
(8, 'public_sink', NULL, '0.0.0.0', 2202, 'tcp');
INSERT INTO links (from_node, to_node) VALUES (1, 5), (5, 2), (5, 6), (3, 4), (7, 8);\""
on target 'echo RELOAD | socat - UNIX-CONNECT:/run/patchbay/api.sock' >/dev/null
echo "waiting for clients to connect..."
for i in $(seq 60); do
st=$(on target 'echo STATUS | socat - UNIX-CONNECT:/run/patchbay/api.sock')
echo "$st" | grep -q '"1":{"since"' && echo "$st" | grep -q '"2":{"since"' && break
sleep 1
done
echo "$st" | grep -q '"2":{"since"' && ok "both clients connected" || { bad "clients did not connect: $st"; $DC logs --tail 40; exit 1; }
sleep 2
r=$(on client2 'echo hello-public | socat -t3 - TCP:target:2200')
[ "$r" = hello-public ] && ok "TCP via public sink" || bad "TCP via public sink: got '$r'"
r=$(on client2 'echo hello-client | socat -t3 - TCP:127.0.0.1:7000')
[ "$r" = hello-client ] && ok "TCP client sink (client2 -> client1)" || bad "client sink: got '$r'"
r=$(on client2 'echo hello-udp | socat -T3 - UDP:target:2201')
[ "$r" = hello-udp ] && ok "UDP via public sink" || bad "UDP via public sink: got '$r'"
r=$(on client2 'head -c 8000000 /dev/urandom > /tmp/blob && socat -t10 - TCP:target:2200 < /tmp/blob | sha256sum | cut -c1-64; sha256sum < /tmp/blob | cut -c1-64')
[ "$(echo "$r" | sed -n 1p)" = "$(echo "$r" | sed -n 2p)" ] && ok "8 MB echo integrity" || bad "integrity: $r"
r=$(on client2 'for i in $(seq 20); do (echo "par$i" | socat -t5 - TCP:target:2200) & done; wait' | sort | uniq | wc -l)
[ "$r" = 20 ] && ok "20 parallel connections" || bad "parallel connections: $r distinct answers"
r=$(on client2 'start=$(date +%s.%N); head -c 500000000 /dev/zero | socat -u - TCP:target:2202; end=$(date +%s.%N); echo "$start $end" | awk "{printf \"%.0f\", 500/(\$2-\$1)}"')
[ -n "$r" ] && [ "$r" -gt 0 ] 2>/dev/null && ok "throughput 500 MB: ${r} MB/s" || bad "throughput: $r"
sleep 1
r=$(on target 'echo LIVE | socat - UNIX-CONNECT:/run/patchbay/api.sock')
echo "$r" | grep -Eq '"8":\{[^}]*"total_in":[0-9]{9}' && ok "stats count bytes" || bad "stats: $r"
r=$(on target "sqlite3 /etc/patchbay/patchbay.db \"SELECT COUNT(*) FROM services WHERE client_id = 1 AND port = 9000\"")
[ "$r" = 1 ] && ok "Services report from client1" || bad "Services: $r rows"
# Tunnel nodes: a veth pair named tunN stands in for a VPN interface, its peer
# end lives in network namespace "peer" with echo services on 10.77.0.2.
mktun() {
on "$1" "ip netns add peer && ip link add $2 type veth peer name p0 && ip link set p0 netns peer &&
ip addr add 10.77.0.1/24 dev $2 && ip link set $2 up &&
ip netns exec peer ip addr add 10.77.0.2/24 dev p0 && ip netns exec peer ip link set p0 up &&
ip netns exec peer ip link set lo up &&
(ip netns exec peer socat TCP-LISTEN:9100,fork,reuseaddr EXEC:cat >/dev/null 2>&1 &) &&
(ip netns exec peer socat UDP-RECVFROM:9101,fork EXEC:cat >/dev/null 2>&1 &)"
}
mktun target tun7
mktun client1 tun8
mktun client2 tun9
on target "sqlite3 /etc/patchbay/patchbay.db \"
INSERT INTO nodes (id, type, client_id, host, port, proto, iface) VALUES
(20, 'tunnel_source', NULL, '10.77.0.2', 9100, 'tcp', 'tun7'),
(21, 'public_sink', NULL, '0.0.0.0', 2210, 'tcp', ''),
(22, 'tunnel_source', NULL, '10.77.0.2', 9101, 'udp', 'tun7'),
(23, 'public_sink', NULL, '0.0.0.0', 2211, 'udp', ''),
(24, 'tunnel_sink', NULL, '', 9200, 'tcp', 'tun7'),
(25, 'client_source', 1, '127.0.0.1', 9000, 'tcp', ''),
(26, 'tunnel_source', 1, '10.77.0.2', 9100, 'tcp', 'tun8'),
(27, 'public_sink', NULL, '0.0.0.0', 2212, 'tcp', ''),
(28, 'tunnel_sink', 2, '', 9300, 'tcp', 'tun9'),
(29, 'client_source', 1, '127.0.0.1', 9000, 'tcp', ''),
(30, 'tunnel_sink', 2, '', 9301, 'udp', 'tun9'),
(31, 'tunnel_source', NULL, '10.77.0.2', 9101, 'udp', 'tun7');
INSERT INTO links (from_node, to_node) VALUES (20, 21), (22, 23), (25, 24), (26, 27), (29, 28), (31, 30);\""
on target 'echo RELOAD | socat - UNIX-CONNECT:/run/patchbay/api.sock' >/dev/null
sleep 3
r=$(on client2 'echo t-src-target | socat -t3 - TCP:target:2210')
[ "$r" = t-src-target ] && ok "tunnel source on target (TCP)" || bad "tunnel source on target: got '$r'"
r=$(on client2 'echo t-src-udp | socat -T3 - UDP:target:2211')
[ "$r" = t-src-udp ] && ok "tunnel source on target (UDP)" || bad "tunnel source UDP: got '$r'"
r=$(on target 'echo t-sink-target | ip netns exec peer socat -t3 - TCP:10.77.0.1:9200')
[ "$r" = t-sink-target ] && ok "tunnel sink on target" || bad "tunnel sink on target: got '$r'"
r=$(on target 'echo not-via-tun | socat -t2 - TCP:127.0.0.1:9200 2>&1')
[ "$r" != not-via-tun ] && ok "tunnel sink only accepts traffic from its interface" || bad "tunnel sink reachable via lo"
r=$(on client2 'echo t-src-client | socat -t3 - TCP:target:2212')
[ "$r" = t-src-client ] && ok "tunnel source on client1" || bad "tunnel source on client: got '$r'"
r=$(on client2 'echo t-sink-client | ip netns exec peer socat -t3 - TCP:10.77.0.1:9300')
[ "$r" = t-sink-client ] && ok "tunnel sink on client2 (TCP)" || bad "tunnel sink on client: got '$r'"
r=$(on client2 'echo t-sink-udp | ip netns exec peer socat -T3 - UDP:10.77.0.1:9301')
[ "$r" = t-sink-udp ] && ok "tunnel sink on client2 -> target tunnel source (UDP)" || bad "client tunnel sink UDP: got '$r'"
on target 'echo SERVICES | socat - UNIX-CONNECT:/run/patchbay/api.sock' >/dev/null
sleep 2
r=$(on target "sqlite3 /etc/patchbay/patchbay.db \"SELECT COUNT(*) FROM interfaces WHERE (client_id = 0 AND name = 'tun7') OR (client_id = 1 AND name = 'tun8')\"")
[ "$r" = 2 ] && ok "tun interfaces reported" || bad "interfaces: $r rows"
# Web login over HTTPS with the emailed (logged) code.
r=$(on target 'set -e
J=/tmp/jar; rm -f $J; U=https://127.0.0.1:8443
tok=$(curl -sk -c $J -b $J $U/login | sed -n "s/.*csrf-token\" content=\"\([^\"]*\)\".*/\1/p")
curl -sk -c $J -b $J -o /dev/null --data-urlencode "csrf_token=$tok" -d username=admin -d password=patchbay-test-pw $U/login
echo $tok' 2>&1)
sleep 1
code=$($DC logs target 2>&1 | sed -n 's/.*login code is: \([0-9]\{6\}\).*/\1/p' | tail -1)
r=$(on target "set -e
J=/tmp/jar; U=https://127.0.0.1:8443
tok=\$(curl -sk -c \$J -b \$J \$U/verify | sed -n 's/.*csrf-token\" content=\"\([^\"]*\)\".*/\1/p')
curl -sk -c \$J -b \$J -o /dev/null -d csrf_token=\$tok -d code=$code \$U/verify
curl -sk -c \$J -b \$J \$U/api/graph")
echo "$r" | grep -q '"client_source"' && ok "web login with email code + API" || bad "web login: code='$code' $r"
echo
echo "$PASS passed, $FAIL failed"
[ $FAIL = 0 ]

View File

@@ -0,0 +1,15 @@
# PatchBay client. Install as /etc/patchbay/patchbay.conf.
Role = client
TargetHost = gateway.example.org
TargetPort = 2222
# Optional dedicated key. Without it, root's key (/root/.ssh/id_ed25519) and
# then the host key (/etc/ssh/ssh_host_ed25519_key) are used.
# Print the public key to add in the web UI with: patchbayd --pubkey
# IdentityFile = /etc/patchbay/id_ed25519
# IdentityPassphrase =
# The target's host key is trusted on first connect and pinned here.
# KnownHosts = /etc/patchbay/known_hosts
# ServicesInterval = 30
# LogLevel = info

View File

@@ -0,0 +1,39 @@
# PatchBay target (exit gateway). Install as /etc/patchbay/patchbay.conf, mode 600.
Role = target
# Port of the dedicated PatchBay sshd that clients connect to.
TargetPort = 2222
# Loopback port the sshd forwards data channels to (never exposed).
HubPort = 7701
# Web interface (HTTPS). A self-signed certificate is created if missing.
WebBind = 0.0.0.0
WebPort = 8443
TLSCert = /etc/patchbay/tls.crt
TLSKey = /etc/patchbay/tls.key
SessionHours = 12
# Sysop: the only account that manages users and server settings.
# A plaintext password is replaced by its hash on the first web start.
SysopUser = admin
SysopEmail = admin@example.org
SysopPassword = change-me-please
# Mail server for login codes. MailSecurity: ssl, starttls or none.
# MailHost = log prints mails to the web log (testing only).
MailHost = smtp.example.org
MailPort = 587
MailSecurity = starttls
MailUser = patchbay@example.org
MailPassword = secret
MailFrom = patchbay@example.org
# Optional overrides:
# Database = /etc/patchbay/patchbay.db
# RunDir = /run/patchbay
# SSHUser = patchbay
# SSHHostKey = /etc/patchbay/ssh_host_ed25519_key
# AuthorizedKeys = /etc/patchbay/authorized_keys
# DaemonPath = /usr/local/sbin/patchbayd
# ServicesInterval = 30
# LogLevel = info

11
init/openrc/patchbay-sshd Executable file
View File

@@ -0,0 +1,11 @@
#!/sbin/openrc-run
description="PatchBay dedicated sshd (target only)"
supervisor=supervise-daemon
command=/usr/local/lib/patchbay/patchbay-sshd
command_args="/etc/patchbay/patchbay.conf"
output_log=/var/log/patchbay-sshd.log
error_log=/var/log/patchbay-sshd.log
depend() {
need net patchbayd
}

12
init/openrc/patchbay-web Executable file
View File

@@ -0,0 +1,12 @@
#!/sbin/openrc-run
description="PatchBay web interface (target only)"
supervisor=supervise-daemon
command=/usr/bin/python3
command_args="-m patchbay_web -c /etc/patchbay/patchbay.conf serve"
directory=/usr/local/lib/patchbay/web
output_log=/var/log/patchbay-web.log
error_log=/var/log/patchbay-web.log
depend() {
need net patchbayd
}

20
init/openrc/patchbayd Executable file
View File

@@ -0,0 +1,20 @@
#!/sbin/openrc-run
description="PatchBay daemon (target hub or client)"
supervisor=supervise-daemon
command=/usr/local/sbin/patchbayd
command_args="-c /etc/patchbay/patchbay.conf"
output_log=/var/log/patchbayd.log
error_log=/var/log/patchbayd.log
extra_started_commands="reload"
depend() {
need net
}
start_pre() {
checkpath -d -m 0755 /run/patchbay
}
reload() {
supervise-daemon "$RC_SVCNAME" --signal HUP
}

4
init/runit/patchbay-sshd/run Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
exec 2>&1
sv check patchbayd >/dev/null || exit 1
exec /usr/local/lib/patchbay/patchbay-sshd /etc/patchbay/patchbay.conf

5
init/runit/patchbay-web/run Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/sh
exec 2>&1
sv check patchbayd >/dev/null || exit 1
cd /usr/local/lib/patchbay/web || exit 1
exec python3 -m patchbay_web -c /etc/patchbay/patchbay.conf serve

4
init/runit/patchbayd/run Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
exec 2>&1
mkdir -p /run/patchbay
exec /usr/local/sbin/patchbayd -c /etc/patchbay/patchbay.conf

View File

@@ -0,0 +1,14 @@
[Unit]
Description=PatchBay dedicated sshd (target only)
After=network-online.target patchbayd.service
Wants=patchbayd.service
[Service]
ExecStart=/usr/local/lib/patchbay/patchbay-sshd /etc/patchbay/patchbay.conf
Restart=on-failure
RestartSec=2
RuntimeDirectory=patchbay
RuntimeDirectoryPreserve=yes
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=PatchBay web interface (target only)
After=network-online.target patchbayd.service
Wants=patchbayd.service
[Service]
WorkingDirectory=/usr/local/lib/patchbay/web
ExecStart=/usr/bin/python3 -m patchbay_web -c /etc/patchbay/patchbay.conf serve
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
[Unit]
Description=PatchBay daemon (target hub or client)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/sbin/patchbayd -c /etc/patchbay/patchbay.conf
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2
RuntimeDirectory=patchbay
RuntimeDirectoryPreserve=yes
[Install]
WantedBy=multi-user.target

131
install.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/bin/sh
# PatchBay installer: dependencies (APT or XBPS), build, install, service units.
# Usage: ./install.sh --role target|client [--init systemd|runit|openrc|none] [--no-deps]
set -eu
ROLE=""
INIT=auto
DEPS=1
PREFIX=${PREFIX:-/usr/local}
while [ $# -gt 0 ]; do
case "$1" in
--role) ROLE=$2; shift 2 ;;
--init) INIT=$2; shift 2 ;;
--no-deps) DEPS=0; shift ;;
-h|--help) sed -n '2,3p' "$0"; exit 0 ;;
*) echo "unknown option $1" >&2; exit 2 ;;
esac
done
case "$ROLE" in
target|client) ;;
*) echo "usage: $0 --role target|client [--init systemd|runit|openrc|none] [--no-deps]" >&2; exit 2 ;;
esac
[ "$(id -u)" = 0 ] || { echo "run as root" >&2; exit 1; }
cd "$(dirname "$0")"
say() { printf '==> %s\n' "$*"; }
# Dependencies
if [ $DEPS = 1 ]; then
if command -v apt-get >/dev/null; then
PKGS="build-essential pkg-config libssh2-1-dev libsqlite3-dev openssh-client"
[ "$ROLE" = target ] && PKGS="$PKGS openssh-server python3 python3-flask python3-cryptography"
say "installing packages via APT: $PKGS"
apt-get update
# shellcheck disable=SC2086
DEBIAN_FRONTEND=noninteractive apt-get install -y $PKGS
elif command -v xbps-install >/dev/null; then
PKGS="base-devel pkg-config libssh2-devel sqlite-devel openssh"
[ "$ROLE" = target ] && PKGS="$PKGS shadow python3 python3-Flask python3-cryptography"
say "installing packages via XBPS: $PKGS"
xbps-install -Sy
# shellcheck disable=SC2086
xbps-install -y $PKGS
else
echo "neither apt-get nor xbps-install found; install dependencies manually and use --no-deps" >&2
exit 1
fi
fi
# Build and install
say "building"
make clean >/dev/null
make all
make install PREFIX="$PREFIX"
mkdir -p /etc/patchbay
chmod 755 /etc/patchbay
if [ ! -f /etc/patchbay/patchbay.conf ]; then
install -m 600 "examples/patchbay.conf.$ROLE" /etc/patchbay/patchbay.conf
if [ "$PREFIX" != /usr/local ]; then
echo "DaemonPath = $PREFIX/sbin/patchbayd" >> /etc/patchbay/patchbay.conf
fi
NEWCONF=1
else
NEWCONF=0
fi
if [ "$ROLE" = target ]; then
# The sshd login user: no password, shell needed for the forced command.
if ! id patchbay >/dev/null 2>&1; then
say "creating user patchbay"
useradd --system --home-dir /nonexistent --no-create-home --shell /bin/sh patchbay
fi
usermod -p '*' patchbay
fi
# Service units
if [ "$INIT" = auto ]; then
if [ -d /run/systemd/system ]; then INIT=systemd
elif command -v openrc-run >/dev/null; then INIT=openrc
elif command -v sv >/dev/null; then INIT=runit
else INIT=none
fi
fi
SERVICES=patchbayd
[ "$ROLE" = target ] && SERVICES="patchbayd patchbay-sshd patchbay-web"
fix_prefix() { [ "$PREFIX" = /usr/local ] || sed -i "s|/usr/local|$PREFIX|g" "$1"; }
case "$INIT" in
systemd)
for s in $SERVICES; do
install -m 644 "init/systemd/$s.service" "/etc/systemd/system/$s.service"
fix_prefix "/etc/systemd/system/$s.service"
done
systemctl daemon-reload
ENABLE="systemctl enable --now $SERVICES"
;;
openrc)
for s in $SERVICES; do
install -m 755 "init/openrc/$s" "/etc/init.d/$s"
fix_prefix "/etc/init.d/$s"
done
ENABLE=$(for s in $SERVICES; do printf 'rc-update add %s default && rc-service %s start; ' "$s" "$s"; done)
;;
runit)
SVDIR=/etc/sv
[ -d /etc/sv ] || SVDIR=/etc/runit/sv
RUNSVDIR=/var/service
[ -d /var/service ] || RUNSVDIR=/etc/runit/runsvdir/default
for s in $SERVICES; do
mkdir -p "$SVDIR/$s"
install -m 755 "init/runit/$s/run" "$SVDIR/$s/run"
fix_prefix "$SVDIR/$s/run"
done
ENABLE=$(for s in $SERVICES; do printf 'ln -s %s/%s %s/; ' "$SVDIR" "$s" "$RUNSVDIR"; done)
;;
none) ENABLE="(no init system selected; run $PREFIX/sbin/patchbayd manually)" ;;
*) echo "unknown init system $INIT" >&2; exit 2 ;;
esac
say "installed ($ROLE, $INIT)"
[ $NEWCONF = 1 ] && echo " edit /etc/patchbay/patchbay.conf before starting"
if [ "$ROLE" = client ]; then
echo " add this client's key in the target's web UI (Settings -> Clients):"
"$PREFIX/sbin/patchbayd" --pubkey 2>/dev/null | sed 's/^/ /' || \
echo " (no key yet; set IdentityFile or create /root/.ssh/id_ed25519, then run: patchbayd --pubkey)"
fi
echo " start with: $ENABLE"

107
schema.sql Normal file
View File

@@ -0,0 +1,107 @@
-- PatchBay SQLite schema, shared by patchbayd (embedded at build time) and the
-- web frontend (copied into the package on install). Must stay idempotent.
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Users besides the sysop (who lives in patchbay.conf).
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
pwhash TEXT NOT NULL,
created INTEGER NOT NULL
);
-- Pending email 2FA logins.
CREATE TABLE IF NOT EXISTS login_codes (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
codehash TEXT NOT NULL,
expires INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0
);
-- Failed login attempts for rate limiting (per remote address).
CREATE TABLE IF NOT EXISTS login_failures (
addr TEXT NOT NULL,
ts INTEGER NOT NULL
);
-- Authorised clients. pubkey is "type base64" without comment.
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
pubkey TEXT NOT NULL UNIQUE,
added_by TEXT NOT NULL,
created INTEGER NOT NULL,
hostname TEXT NOT NULL DEFAULT '',
last_addr TEXT NOT NULL DEFAULT '',
last_seen INTEGER NOT NULL DEFAULT 0
);
-- Patch graph. type: client_source, client_sink, public_sink, splitter,
-- tunnel_source, tunnel_sink. host: service address (sources) / bind address
-- (sinks). Tunnel nodes use iface; their client_id NULL means the target.
-- Databases created before iface existed are migrated by both programs.
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL,
client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 0,
proto TEXT NOT NULL DEFAULT 'tcp',
label TEXT NOT NULL DEFAULT '',
iface TEXT NOT NULL DEFAULT '',
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY,
from_node INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
to_node INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
UNIQUE (to_node)
);
-- Listening sockets per host, reported by the daemons. client_id 0 = target.
CREATE TABLE IF NOT EXISTS services (
client_id INTEGER NOT NULL,
proto TEXT NOT NULL,
addr TEXT NOT NULL,
port INTEGER NOT NULL,
pid INTEGER NOT NULL,
process TEXT NOT NULL,
updated INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS services_client ON services (client_id);
-- tun interfaces per host as reported by the daemons. client_id 0 = target.
CREATE TABLE IF NOT EXISTS interfaces (
client_id INTEGER NOT NULL,
name TEXT NOT NULL,
addr TEXT NOT NULL,
updated INTEGER NOT NULL
);
-- Traffic per connection (= per sink node). tier: 'm' minute, 'h' hour, 'd' day.
-- bytes_in: towards the source service, bytes_out: back to the connecting peer.
CREATE TABLE IF NOT EXISTS stats (
sink_id INTEGER NOT NULL,
tier TEXT NOT NULL,
ts INTEGER NOT NULL,
bytes_in INTEGER NOT NULL DEFAULT 0,
bytes_out INTEGER NOT NULL DEFAULT 0,
conns INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (sink_id, tier, ts)
);
CREATE INDEX IF NOT EXISTS stats_tier_ts ON stats (tier, ts);
INSERT OR IGNORE INTO settings (key, value) VALUES
('stats_minute_hours', '48'),
('stats_hour_days', '90'),
('stats_day_days', '0');

36
sshd/patchbay-sshd Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/sh
# Starts the dedicated PatchBay sshd in the foreground. Used by all init systems.
# Usage: patchbay-sshd [config] (default /etc/patchbay/patchbay.conf)
set -eu
CONF=${1:-/etc/patchbay/patchbay.conf}
LIBDIR=$(dirname "$(readlink -f "$0")")
PATCHBAYD=${PATCHBAYD:-patchbayd}
SSHD=${SSHD:-$(command -v sshd || echo /usr/sbin/sshd)}
get() {
"$PATCHBAYD" -c "$CONF" --print-config | sed -n "s/^$1=//p"
}
RUNDIR=$(get RunDir)
HOSTKEY=$(get SSHHostKey)
mkdir -p "$RUNDIR" /run/sshd
chmod 755 "$RUNDIR"
if [ ! -f "$HOSTKEY" ]; then
ssh-keygen -q -t ed25519 -N '' -C patchbay-target -f "$HOSTKEY"
fi
# authorized_keys must exist before sshd accepts connections.
"$PATCHBAYD" -c "$CONF" --write-keys
sed -e "s|@PORT@|$(get TargetPort)|" \
-e "s|@HUBPORT@|$(get HubPort)|" \
-e "s|@HOSTKEY@|$HOSTKEY|" \
-e "s|@AUTHKEYS@|$(get AuthorizedKeys)|" \
-e "s|@USER@|$(get SSHUser)|" \
-e "s|@RUNDIR@|$RUNDIR|" \
"$LIBDIR/sshd_config.in" > "$RUNDIR/sshd_config"
"$SSHD" -t -f "$RUNDIR/sshd_config"
exec "$SSHD" -D -e -f "$RUNDIR/sshd_config"

38
sshd/sshd_config.in Normal file
View File

@@ -0,0 +1,38 @@
# PatchBay dedicated sshd. Generated by patchbay-sshd from this template;
# values come from patchbay.conf via "patchbayd --print-config".
Port @PORT@
HostKey @HOSTKEY@
PidFile @RUNDIR@/sshd.pid
AuthorizedKeysFile @AUTHKEYS@
AllowUsers @USER@
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
UsePAM no
StrictModes yes
# Clients may only open data channels to the hub port; per-key options in
# authorized_keys repeat this and force the control relay command.
AllowTcpForwarding local
PermitOpen 127.0.0.1:@HUBPORT@
AllowStreamLocalForwarding no
AllowAgentForwarding no
X11Forwarding no
PermitTunnel no
GatewayPorts no
PermitTTY no
PermitUserRC no
PermitUserEnvironment no
PrintMotd no
Banner none
# Throughput: AEAD ciphers first, no compression.
Ciphers aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com,aes128-ctr,aes256-ctr
Compression no
ClientAliveInterval 30
ClientAliveCountMax 3
MaxStartups 50:30:200
LogLevel INFO

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()