Initial Awawawa
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
build
|
||||
**/__pycache__
|
||||
.git
|
||||
smtp-test-data.conf
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal 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
69
Makefile
Normal 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
30
README.md
Normal 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
1091
backend/src/client.c
Normal file
File diff suppressed because it is too large
Load Diff
13
backend/src/client.h
Normal file
13
backend/src/client.h
Normal 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
129
backend/src/config.c
Normal 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
39
backend/src/config.h
Normal 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
54
backend/src/db.c
Normal 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
13
backend/src/db.h
Normal 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
1724
backend/src/hub.c
Normal file
File diff suppressed because it is too large
Load Diff
14
backend/src/hub.h
Normal file
14
backend/src/hub.h
Normal 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
31
backend/src/json.c
Normal 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
9
backend/src/json.h
Normal 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
33
backend/src/log.c
Normal 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
14
backend/src/log.h
Normal 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
120
backend/src/main.c
Normal 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
216
backend/src/net.c
Normal 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
29
backend/src/net.h
Normal 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
152
backend/src/proto.c
Normal 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
73
backend/src/proto.h
Normal 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
66
backend/src/relay.c
Normal 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
10
backend/src/relay.h
Normal 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
213
backend/src/services.c
Normal 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
30
backend/src/services.h
Normal 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
141
backend/src/stats.c
Normal 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
33
backend/src/stats.h
Normal 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
145
backend/tests/test_main.c
Normal 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
18
docker/Dockerfile
Normal 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
45
docker/compose.yml
Normal 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
65
docker/entrypoint.sh
Executable 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
164
docker/run-tests.sh
Executable 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 ]
|
||||
15
examples/patchbay.conf.client
Normal file
15
examples/patchbay.conf.client
Normal 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
|
||||
39
examples/patchbay.conf.target
Normal file
39
examples/patchbay.conf.target
Normal 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
11
init/openrc/patchbay-sshd
Executable 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
12
init/openrc/patchbay-web
Executable 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
20
init/openrc/patchbayd
Executable 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
4
init/runit/patchbay-sshd/run
Executable 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
5
init/runit/patchbay-web/run
Executable 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
4
init/runit/patchbayd/run
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
exec 2>&1
|
||||
mkdir -p /run/patchbay
|
||||
exec /usr/local/sbin/patchbayd -c /etc/patchbay/patchbay.conf
|
||||
14
init/systemd/patchbay-sshd.service
Normal file
14
init/systemd/patchbay-sshd.service
Normal 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
|
||||
13
init/systemd/patchbay-web.service
Normal file
13
init/systemd/patchbay-web.service
Normal 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
|
||||
15
init/systemd/patchbayd.service
Normal file
15
init/systemd/patchbayd.service
Normal 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
131
install.sh
Executable 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
107
schema.sql
Normal 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
36
sshd/patchbay-sshd
Executable 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
38
sshd/sshd_config.in
Normal 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
|
||||
67
web/patchbay_web/__init__.py
Normal file
67
web/patchbay_web/__init__.py
Normal 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
|
||||
60
web/patchbay_web/__main__.py
Normal file
60
web/patchbay_web/__main__.py
Normal 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
123
web/patchbay_web/api.py
Normal 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
192
web/patchbay_web/auth.py
Normal 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
119
web/patchbay_web/conf.py
Normal 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
|
||||
29
web/patchbay_web/daemon.py
Normal file
29
web/patchbay_web/daemon.py
Normal 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
67
web/patchbay_web/db.py
Normal 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())
|
||||
43
web/patchbay_web/mailer.py
Normal file
43
web/patchbay_web/mailer.py
Normal 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
|
||||
61
web/patchbay_web/security.py
Normal file
61
web/patchbay_web/security.py
Normal 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}"
|
||||
71
web/patchbay_web/static/common.js
Normal file
71
web/patchbay_web/static/common.js
Normal 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 };
|
||||
})();
|
||||
634
web/patchbay_web/static/patch.js
Normal file
634
web/patchbay_web/static/patch.js
Normal 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();
|
||||
})();
|
||||
15
web/patchbay_web/static/services.js
Normal file
15
web/patchbay_web/static/services.js
Normal 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;
|
||||
}
|
||||
});
|
||||
245
web/patchbay_web/static/stats.js
Normal file
245
web/patchbay_web/static/stats.js
Normal 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);
|
||||
})();
|
||||
246
web/patchbay_web/static/style.css
Normal file
246
web/patchbay_web/static/style.css
Normal 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; }
|
||||
}
|
||||
41
web/patchbay_web/templates/base.html
Normal file
41
web/patchbay_web/templates/base.html
Normal 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>
|
||||
14
web/patchbay_web/templates/login.html
Normal file
14
web/patchbay_web/templates/login.html
Normal 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 %}
|
||||
31
web/patchbay_web/templates/patch.html
Normal file
31
web/patchbay_web/templates/patch.html
Normal 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 ▾</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 %}
|
||||
38
web/patchbay_web/templates/services.html
Normal file
38
web/patchbay_web/templates/services.html
Normal 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 %}
|
||||
122
web/patchbay_web/templates/settings.html
Normal file
122
web/patchbay_web/templates/settings.html
Normal 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 %}
|
||||
61
web/patchbay_web/templates/stats.html
Normal file
61
web/patchbay_web/templates/stats.html
Normal 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 %}
|
||||
14
web/patchbay_web/templates/verify.html
Normal file
14
web/patchbay_web/templates/verify.html
Normal 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
42
web/patchbay_web/tls.py
Normal 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
|
||||
141
web/patchbay_web/validate.py
Normal file
141
web/patchbay_web/validate.py
Normal 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
232
web/patchbay_web/views.py
Normal 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
0
web/tests/__init__.py
Normal file
55
web/tests/helpers.py
Normal file
55
web/tests/helpers.py
Normal 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
75
web/tests/test_auth.py
Normal 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
27
web/tests/test_conf.py
Normal 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
96
web/tests/test_graph.py
Normal 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()
|
||||
Reference in New Issue
Block a user