commit 8e83a98ef4177d8b36c15c4cab08bbbab0e49e1c Author: mueller_minki Date: Sun Oct 4 09:08:27 2026 +0200 Initial Awawawa diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a05fc70 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +build +**/__pycache__ +.git +smtp-test-data.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be6072c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aa7401b --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f084dc --- /dev/null +++ b/README.md @@ -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://: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 +``` diff --git a/backend/src/client.c b/backend/src/client.c new file mode 100644 index 0000000..43df739 --- /dev/null +++ b/backend/src/client.c @@ -0,0 +1,1091 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "client.h" +#include "log.h" +#include "net.h" +#include "proto.h" +#include "services.h" + +/* Types */ + +enum cc_kind { K_CTL, K_SINK_TCP, K_SINK_UDP, K_SRC_TCP, K_SRC_UDP }; +enum cc_state { S_OPENING, S_ACTIVE, S_CLOSING }; + +// Local listener of a client sink. +struct csink { + struct csink *next; + int sink_id, proto, port; + char bind[64]; + char iface[IFNAMSIZ]; // tunnel sink: pinned to this interface + unsigned ifindex; + int fd; // -1 while binding fails; retried by sinks_retry() + int keep; + char state[96]; // last SINKSTATE sent, to report only changes +}; + +// One SSH channel and the local socket it is bridged to. +struct cc { + struct cc *next; + int kind, state; + int killed; // kill requested while the channel was still opening + LIBSSH2_CHANNEL *ch; + int fd; // local socket, -1 for control and sink UDP flows + int connecting; + struct buf to_ch; // local -> channel (starts with the PB1 header) + struct buf from_ch; // channel -> local (raw or UDP frames) + int local_eof, eof_sent, ch_eof, local_shut; + struct csink *sink; // K_SINK_UDP: listener to answer through + struct sockaddr_storage peer; + socklen_t peerlen; + long last_act; +}; + +// libssh2 requires the exact same call to be repeated after it returned +// EAGAIN with a partially sent packet; no other call may be made before. +enum { OP_NONE, OP_OPEN, OP_WRITE, OP_EOF, OP_READ, OP_FREE, OP_KEEPALIVE }; + +struct pending_op { + int op; + struct cc *c; + size_t len; +}; + +/* State */ + +static const struct pb_config *cfg; +static LIBSSH2_SESSION *sess; +static int sock = -1; +static struct cc *ccs; +static struct cc *ctl; +static struct cc *opening; // channel open in progress (session-wide state in libssh2) +static struct csink *csinks; +static struct pending_op pending; +static char token[PB_TOKEN_LEN + 1]; +static int hub_port; +static int hello_done; +static long last_svc, last_retry; + +static volatile sig_atomic_t sig_stop; + +#define BUF_LIMIT (256 * 1024) +#define UDP_IDLE 120 +#define READ_CHUNK 65536 + +static long now_s(void) +{ + return (long)time(NULL); +} + +static int out_blocked(void) +{ + return libssh2_session_block_directions(sess) & LIBSSH2_SESSION_BLOCK_OUTBOUND; +} + +// Errors that only concern one channel; everything else kills the session. +static int channel_error(int rc) +{ + return rc == LIBSSH2_ERROR_CHANNEL_FAILURE || rc == LIBSSH2_ERROR_CHANNEL_CLOSED || + rc == LIBSSH2_ERROR_CHANNEL_EOF_SENT || rc == LIBSSH2_ERROR_CHANNEL_UNKNOWN || + rc == LIBSSH2_ERROR_CHANNEL_OUTOFORDER || rc == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED || + rc == LIBSSH2_ERROR_CHANNEL_WINDOW_EXCEEDED || rc == LIBSSH2_ERROR_CHANNEL_PACKET_EXCEEDED; +} + +/* Channel bookkeeping */ + +static struct cc *cc_new(int kind, int fd) +{ + struct cc *c = calloc(1, sizeof(*c)); + if (!c) + return NULL; + c->kind = kind; + c->state = S_OPENING; + c->fd = fd; + c->last_act = now_s(); + buf_init(&c->to_ch); + buf_init(&c->from_ch); + // Append to keep channel opens in FIFO order. + struct cc **pp = &ccs; + while (*pp) + pp = &(*pp)->next; + *pp = c; + return c; +} + +static void cc_kill(struct cc *c) +{ + if (c->fd >= 0) { + close(c->fd); + c->fd = -1; + } + if (c->state == S_OPENING) + c->killed = 1; + else + c->state = S_CLOSING; +} + +static void cc_remove(struct cc *c) +{ + for (struct cc **pp = &ccs; *pp; pp = &(*pp)->next) { + if (*pp == c) { + *pp = c->next; + break; + } + } + if (c->fd >= 0) + close(c->fd); + buf_free(&c->to_ch); + buf_free(&c->from_ch); + if (pending.c == c) + pending.op = OP_NONE; + free(c); +} + +static void ctl_send(const char *fmt, ...) __attribute__((format(printf, 1, 2))); +static void ctl_send(const char *fmt, ...) +{ + char line[PB_MAX_LINE]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(line, sizeof(line) - 1, fmt, ap); + va_end(ap); + if (!ctl || n < 0 || (size_t)n >= sizeof(line) - 1) + return; + line[n++] = '\n'; + buf_append(&ctl->to_ch, line, (size_t)n); +} + +/* libssh2 operations: 0 = done/idle, 1 = pending (stop), -1 = session dead */ + +static int do_open(void) +{ + if (!opening) { + for (struct cc *c = ccs; c; c = c->next) + if (c->state == S_OPENING) { + opening = c; + break; + } + if (!opening) + return 0; + } + LIBSSH2_CHANNEL *ch = libssh2_channel_direct_tcpip_ex(sess, "127.0.0.1", hub_port, "127.0.0.1", 0); + if (!ch) { + int rc = libssh2_session_last_errno(sess); + if (rc == LIBSSH2_ERROR_EAGAIN) { + if (out_blocked()) { + pending.op = OP_OPEN; + pending.c = opening; + return 1; + } + return 0; + } + struct cc *c = opening; + opening = NULL; + if (!channel_error(rc)) + return -1; + log_warn("data channel open refused (%d)", rc); + cc_remove(c); + return 0; + } + struct cc *c = opening; + opening = NULL; + c->ch = ch; + c->state = c->killed ? S_CLOSING : S_ACTIVE; + return 0; +} + +static int do_write(struct cc *c, size_t len) +{ + ssize_t n = libssh2_channel_write(c->ch, c->to_ch.data, len); + if (n == LIBSSH2_ERROR_EAGAIN) { + if (out_blocked()) { + pending.op = OP_WRITE; + pending.c = c; + pending.len = len; + return 1; + } + return 0; + } + if (n < 0) { + if (!channel_error((int)n)) + return -1; + cc_kill(c); + return 0; + } + buf_consume(&c->to_ch, (size_t)n); + c->last_act = now_s(); + return 0; +} + +static int do_eof(struct cc *c) +{ + int rc = libssh2_channel_send_eof(c->ch); + if (rc == LIBSSH2_ERROR_EAGAIN) { + if (out_blocked()) { + pending.op = OP_EOF; + pending.c = c; + return 1; + } + return 0; + } + if (rc < 0 && !channel_error(rc)) + return -1; + c->eof_sent = 1; + return 0; +} + +// Reads one chunk. Returns 2 if data was read. +static int do_read(struct cc *c) +{ + char tmp[READ_CHUNK]; + ssize_t n = libssh2_channel_read(c->ch, tmp, sizeof(tmp)); + if (n > 0) { + buf_append(&c->from_ch, tmp, (size_t)n); + c->last_act = now_s(); + return 2; + } + if (n == LIBSSH2_ERROR_EAGAIN && out_blocked()) { + pending.op = OP_READ; + pending.c = c; + return 1; + } + if (n < 0 && n != LIBSSH2_ERROR_EAGAIN) { + if (!channel_error((int)n)) + return -1; + cc_kill(c); + return 0; + } + if (libssh2_channel_eof(c->ch)) + c->ch_eof = 1; + return 0; +} + +static int do_free(struct cc *c) +{ + int rc = libssh2_channel_free(c->ch); + if (rc == LIBSSH2_ERROR_EAGAIN) { + if (out_blocked()) { + pending.op = OP_FREE; + pending.c = c; + return 1; + } + return 0; + } + c->ch = NULL; + if (c == ctl) + ctl = NULL; + cc_remove(c); + return rc < 0 && !channel_error(rc) ? -1 : 0; +} + +static int retry_pending(void) +{ + struct pending_op p = pending; + pending.op = OP_NONE; + switch (p.op) { + case OP_OPEN: + return do_open(); + case OP_WRITE: + return do_write(p.c, p.len); + case OP_EOF: + return do_eof(p.c); + case OP_READ: { + int rc = do_read(p.c); + return rc == 2 ? 0 : rc; + } + case OP_FREE: + return do_free(p.c); + case OP_KEEPALIVE: { + int next; + int rc = libssh2_keepalive_send(sess, &next); + if (rc == LIBSSH2_ERROR_EAGAIN && out_blocked()) { + pending.op = OP_KEEPALIVE; + return 1; + } + return rc < 0 && rc != LIBSSH2_ERROR_EAGAIN ? -1 : 0; + } + } + return 0; +} + +// All SSH-side work for one channel. *data set if anything was read. +static int cc_ssh_io(struct cc *c, int *data) +{ + int rc; + if (c->state == S_CLOSING) + return do_free(c); + if (c->state != S_ACTIVE) + return 0; + + while (c->to_ch.len && !c->eof_sent && c->state == S_ACTIVE) { + size_t before = c->to_ch.len; + size_t len = c->to_ch.len < READ_CHUNK ? c->to_ch.len : READ_CHUNK; + if ((rc = do_write(c, len)) != 0) + return rc; + if (c->to_ch.len == before) + break; // remote window full + } + if (c->state == S_ACTIVE && c->local_eof && !c->to_ch.len && !c->eof_sent) + if ((rc = do_eof(c)) != 0) + return rc; + + while (c->state == S_ACTIVE && !c->ch_eof && c->from_ch.len < BUF_LIMIT) { + rc = do_read(c); + if (rc == 2) { + *data = 1; + continue; + } + if (rc != 0) + return rc; + break; + } + return 0; +} + +/* Local side */ + +static void tcp_local_io(struct cc *c) +{ + if (c->fd < 0) + return; + if (c->connecting) { + int err = 0; + socklen_t l = sizeof(err); + struct pollfd p = { .fd = c->fd, .events = POLLOUT }; + if (poll(&p, 1, 0) <= 0) + return; + getsockopt(c->fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err) { + log_debug("local connect failed: %s", strerror(err)); + cc_kill(c); + return; + } + c->connecting = 0; + } + + while (c->from_ch.len) { + ssize_t n = write(c->fd, c->from_ch.data, c->from_ch.len); + if (n < 0) { + if (errno != EAGAIN) { + cc_kill(c); + return; + } + break; + } + buf_consume(&c->from_ch, (size_t)n); + } + if (c->ch_eof && !c->from_ch.len && !c->local_shut) { + shutdown(c->fd, SHUT_WR); + c->local_shut = 1; + } + + char tmp[READ_CHUNK]; + while (!c->local_eof && c->to_ch.len < BUF_LIMIT) { + ssize_t n = read(c->fd, tmp, sizeof(tmp)); + if (n == 0) { + c->local_eof = 1; + } else if (n < 0) { + if (errno != EAGAIN) + cc_kill(c); + break; + } else { + buf_append(&c->to_ch, tmp, (size_t)n); + } + } + if (c->state == S_ACTIVE && c->eof_sent && c->local_shut) + cc_kill(c); +} + +static void udp_local_io(struct cc *c) +{ + const char *payload; + int len; + while ((len = udp_frame_peek(&c->from_ch, &payload)) != 0) { + size_t plen = len == -2 ? 0 : (size_t)len; + if (c->kind == K_SRC_UDP && c->fd >= 0) + send(c->fd, payload, plen, MSG_DONTWAIT); + else if (c->kind == K_SINK_UDP && c->sink && c->sink->fd >= 0) + sendto(c->sink->fd, payload, plen, MSG_DONTWAIT, (struct sockaddr *)&c->peer, c->peerlen); + buf_consume(&c->from_ch, plen + 2); + } + if (c->kind == K_SRC_UDP && c->fd >= 0) { + char tmp[PB_UDP_MAX]; + for (int i = 0; i < 256; i++) { + ssize_t n = recv(c->fd, tmp, sizeof(tmp), MSG_DONTWAIT); + if (n < 0) + break; + if (c->to_ch.len < BUF_LIMIT) + udp_frame_append(&c->to_ch, tmp, (size_t)n); + c->last_act = now_s(); + } + } + if (c->state == S_ACTIVE && (c->ch_eof || c->last_act + UDP_IDLE < now_s())) + cc_kill(c); +} + +/* Sinks */ + +static void sink_close(struct csink *s) +{ + for (struct cc *c = ccs; c; c = c->next) + if (c->sink == s) { + c->sink = NULL; + cc_kill(c); + } + if (s->fd >= 0) + close(s->fd); + log_info("client sink %d closed", s->sink_id); + free(s); +} + +static void sinks_begin(void) +{ + for (struct csink *s = csinks; s; s = s->next) + s->keep = 0; +} + +static void sink_report(struct csink *s, const char *state) +{ + if (!strcmp(s->state, state)) + return; + snprintf(s->state, sizeof(s->state), "%s", state); + ctl_send("SINKSTATE %d %s", s->sink_id, state); +} + +static void sink_bind(struct csink *s) +{ + unsigned idx = 0; + if (s->iface[0] && !(idx = if_nametoindex(s->iface))) { + sink_report(s, "err interface_not_present"); + return; + } + int fd = net_listen(s->bind, s->port, s->proto, s->iface); + if (fd < 0) { + char state[96]; + snprintf(state, sizeof(state), "err %s", strerror(errno)); + field_sanitise(state + 4); + if (strcmp(s->state, state)) + log_warn("client sink %d: bind %s:%d failed: %s", s->sink_id, s->bind, s->port, strerror(errno)); + sink_report(s, state); + return; + } + s->fd = fd; + s->ifindex = idx; + log_info("client sink %d listening on %s %s:%d%s%s", s->sink_id, proto_name(s->proto), s->bind, s->port, + s->iface[0] ? " dev " : "", s->iface); + sink_report(s, "ok -"); +} + +static void sinks_add(int id, int proto, const char *bind, int port, const char *iface) +{ + if (!strcmp(iface, "-") || !iface_valid(iface)) + iface = ""; + for (struct csink *s = csinks; s; s = s->next) { + if (s->sink_id == id && s->proto == proto && s->port == port && !strcmp(s->bind, bind) && + !strcmp(s->iface, iface)) { + s->keep = 1; + // The hub forgets states on reload; repeat ours. + s->state[0] = '\0'; + sink_report(s, s->fd >= 0 ? "ok -" : "err bind_failed"); + return; + } + } + // Drop an old listener of the same sink before rebinding with new parameters. + for (struct csink **pp = &csinks; *pp;) { + if ((*pp)->sink_id == id) { + struct csink *old = *pp; + *pp = old->next; + sink_close(old); + } else { + pp = &(*pp)->next; + } + } + struct csink *s = calloc(1, sizeof(*s)); + if (!s) + return; + s->sink_id = id; + s->proto = proto; + s->port = port; + s->fd = -1; + snprintf(s->bind, sizeof(s->bind), "%s", bind); + snprintf(s->iface, sizeof(s->iface), "%s", iface); + s->keep = 1; + s->next = csinks; + csinks = s; + sink_bind(s); +} + +// Retries failed binds and rebinds sinks whose tun interface was recreated. +static void sinks_retry(void) +{ + for (struct csink *s = csinks; s; s = s->next) { + if (s->fd >= 0 && s->iface[0] && if_nametoindex(s->iface) != s->ifindex) { + log_info("client sink %d: interface %s changed, rebinding", s->sink_id, s->iface); + for (struct cc *c = ccs; c; c = c->next) + if (c->sink == s) { + c->sink = NULL; + cc_kill(c); + } + close(s->fd); + s->fd = -1; + } + if (s->fd < 0) + sink_bind(s); + } +} + +static void sinks_end(void) +{ + for (struct csink **pp = &csinks; *pp;) { + if (!(*pp)->keep) { + struct csink *s = *pp; + *pp = s->next; + sink_close(s); + } else { + pp = &(*pp)->next; + } + } +} + +static void sink_accept(struct csink *s) +{ + if (s->proto == PROTO_TCP) { + for (;;) { + int fd = accept4(s->fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd < 0) + return; + tune_stream(fd); + struct cc *c = cc_new(K_SINK_TCP, fd); + if (!c) { + close(fd); + continue; + } + buf_printf(&c->to_ch, "PB1 %s SINK %d\n", token, s->sink_id); + } + } + + char tmp[PB_UDP_MAX]; + for (int i = 0; i < 256; i++) { + struct sockaddr_storage peer; + socklen_t plen = sizeof(peer); + ssize_t n = recvfrom(s->fd, tmp, sizeof(tmp), MSG_DONTWAIT, (struct sockaddr *)&peer, &plen); + if (n < 0) + return; + struct cc *c; + for (c = ccs; c; c = c->next) + if (c->kind == K_SINK_UDP && c->sink == s && c->state != S_CLOSING && !c->killed && + c->peerlen == plen && !memcmp(&c->peer, &peer, plen)) + break; + if (!c) { + c = cc_new(K_SINK_UDP, -1); + if (!c) + continue; + c->sink = s; + c->peer = peer; + c->peerlen = plen; + buf_printf(&c->to_ch, "PB1 %s SINK %d\n", token, s->sink_id); + } + if (c->to_ch.len < BUF_LIMIT) + udp_frame_append(&c->to_ch, tmp, (size_t)n); + c->last_act = now_s(); + } +} + +/* Control messages */ + +static void send_services(void) +{ + struct svc_entry *svc = NULL; + int n = services_scan(&svc); + if (n < 0) + return; + ctl_send("SVC-BEGIN"); + for (int i = 0; i < n; i++) { + field_sanitise(svc[i].process); + ctl_send("SVC %s %s %d %d %s", proto_name(svc[i].proto), svc[i].addr, svc[i].port, svc[i].pid, + svc[i].process); + } + ctl_send("SVC-END"); + free(svc); + + struct iface_entry *ifs = NULL; + n = ifaces_scan(&ifs); + if (n >= 0) { + ctl_send("IF-BEGIN"); + for (int i = 0; i < n; i++) + ctl_send("IF %s %s", ifs[i].name, ifs[i].addr); + ctl_send("IF-END"); + } + free(ifs); + last_svc = now_s(); +} + +static void ctl_line(char *line) +{ + char *f[PB_MAX_FIELDS]; + int n = line_split(line, f, PB_MAX_FIELDS); + if (n == 0) + return; + + if (!strcmp(f[0], "HELLO") && n >= 3) { + snprintf(token, sizeof(token), "%s", f[1]); + hub_port = atoi(f[2]); + hello_done = 1; + char host[64]; + if (gethostname(host, sizeof(host)) < 0) + strcpy(host, "unknown"); + host[sizeof(host) - 1] = '\0'; + field_sanitise(host); + ctl_send("HELLO %s %s", PB_VERSION, host); + log_info("registered with target"); + } else if (!hello_done) { + return; + } else if (!strcmp(f[0], "SINKS-BEGIN")) { + sinks_begin(); + } else if (!strcmp(f[0], "SINK") && n >= 5) { + int proto = proto_parse(f[2]); + if (proto >= 0) + sinks_add(atoi(f[1]), proto, f[3], atoi(f[4]), n > 5 ? f[5] : "-"); + } else if (!strcmp(f[0], "SINKS-END")) { + sinks_end(); + } else if (!strcmp(f[0], "OPEN") && n >= 5) { + int proto = proto_parse(f[2]); + int inprog = 0; + const char *iface = n > 5 && strcmp(f[5], "-") && iface_valid(f[5]) ? f[5] : NULL; + int fd = proto < 0 ? -1 : net_connect(f[3], atoi(f[4]), proto, iface, &inprog); + if (fd < 0) { + log_debug("OPEN %s: cannot connect to %s:%s: %s", f[1], f[3], f[4], strerror(errno)); + return; // the hub times out the waiting connection + } + struct cc *c = cc_new(proto == PROTO_UDP ? K_SRC_UDP : K_SRC_TCP, fd); + if (!c) { + close(fd); + return; + } + c->connecting = inprog; + buf_printf(&c->to_ch, "PB1 %s OPEN %s\n", token, f[1]); + } else if (!strcmp(f[0], "SVC-REQ")) { + send_services(); + } else if (!strcmp(f[0], "PING")) { + ctl_send("PONG"); + } +} + +static int process_ctl_input(void) +{ + char line[PB_MAX_LINE]; + int rc; + while (ctl && (rc = line_next(&ctl->from_ch, line, sizeof(line))) == 1) + ctl_line(line); + if (ctl && ctl->ch_eof) { + log_warn("control channel closed by target"); + return -1; + } + return 0; +} + +/* Session setup */ + +int client_identity(const struct pb_config *c, char *out, size_t outsz) +{ + const char *cands[] = { c->identity_file, "/root/.ssh/id_ed25519", "/root/.ssh/id_rsa", + "/etc/ssh/ssh_host_ed25519_key", NULL }; + for (int i = 0; cands[i]; i++) { + if (!cands[i][0]) + continue; + if (access(cands[i], R_OK) == 0) { + snprintf(out, outsz, "%s", cands[i]); + return 0; + } + if (i == 0) { + log_err("IdentityFile %s is not readable", cands[i]); + return -1; + } + } + return -1; +} + +static int verify_host_key(void) +{ + size_t len; + int type; + const char *key = libssh2_session_hostkey(sess, &len, &type); + if (!key) + return -1; + + LIBSSH2_KNOWNHOSTS *kh = libssh2_knownhost_init(sess); + if (!kh) + return -1; + libssh2_knownhost_readfile(kh, cfg->known_hosts, LIBSSH2_KNOWNHOST_FILE_OPENSSH); + + int ktype; + switch (type) { + case LIBSSH2_HOSTKEY_TYPE_RSA: ktype = LIBSSH2_KNOWNHOST_KEY_SSHRSA; break; +#ifdef LIBSSH2_HOSTKEY_TYPE_ED25519 + case LIBSSH2_HOSTKEY_TYPE_ED25519: ktype = LIBSSH2_KNOWNHOST_KEY_ED25519; break; +#endif +#ifdef LIBSSH2_HOSTKEY_TYPE_ECDSA_256 + case LIBSSH2_HOSTKEY_TYPE_ECDSA_256: ktype = LIBSSH2_KNOWNHOST_KEY_ECDSA_256; break; + case LIBSSH2_HOSTKEY_TYPE_ECDSA_384: ktype = LIBSSH2_KNOWNHOST_KEY_ECDSA_384; break; + case LIBSSH2_HOSTKEY_TYPE_ECDSA_521: ktype = LIBSSH2_KNOWNHOST_KEY_ECDSA_521; break; +#endif + default: ktype = LIBSSH2_KNOWNHOST_KEY_UNKNOWN; break; + } + + struct libssh2_knownhost *found; + int rc = libssh2_knownhost_checkp(kh, cfg->target_host, cfg->target_port, key, len, + LIBSSH2_KNOWNHOST_TYPE_PLAIN | LIBSSH2_KNOWNHOST_KEYENC_RAW | ktype, + &found); + int ok = -1; + if (rc == LIBSSH2_KNOWNHOST_CHECK_MATCH) { + ok = 0; + } else if (rc == LIBSSH2_KNOWNHOST_CHECK_NOTFOUND) { + // Trust on first use; pre-seed the file to pin the key instead. + char name[300]; + if (cfg->target_port == 22) + snprintf(name, sizeof(name), "%s", cfg->target_host); + else + snprintf(name, sizeof(name), "[%s]:%d", cfg->target_host, cfg->target_port); + libssh2_knownhost_addc(kh, name, NULL, key, len, NULL, 0, + LIBSSH2_KNOWNHOST_TYPE_PLAIN | LIBSSH2_KNOWNHOST_KEYENC_RAW | ktype, NULL); + mode_t old = umask(0022); + libssh2_knownhost_writefile(kh, cfg->known_hosts, LIBSSH2_KNOWNHOST_FILE_OPENSSH); + umask(old); + log_warn("target host key not known, trusting it and saving to %s", cfg->known_hosts); + ok = 0; + } else if (rc == LIBSSH2_KNOWNHOST_CHECK_MISMATCH) { + log_err("TARGET HOST KEY MISMATCH for %s (see %s); refusing to connect", cfg->target_host, cfg->known_hosts); + } else { + log_err("host key check failed"); + } + libssh2_knownhost_free(kh); + return ok; +} + +static int tcp_connect_blocking(void) +{ + struct addrinfo hints, *res; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + char port[16]; + snprintf(port, sizeof(port), "%d", cfg->target_port); + int rc = getaddrinfo(cfg->target_host, port, &hints, &res); + if (rc != 0) { + log_err("cannot resolve %s: %s", cfg->target_host, gai_strerror(rc)); + return -1; + } + int fd = -1; + for (struct addrinfo *ai = res; ai; ai = ai->ai_next) { + fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC, 0); + if (fd < 0) + continue; + struct timeval tv = { .tv_sec = 10 }; + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) + break; + close(fd); + fd = -1; + } + freeaddrinfo(res); + if (fd < 0) + log_err("cannot connect to %s:%d: %s", cfg->target_host, cfg->target_port, strerror(errno)); + else + tune_stream(fd); + return fd; +} + +static int session_open(void) +{ + char ident[256], pub[300]; + if (client_identity(cfg, ident, sizeof(ident)) < 0) { + log_err("no usable SSH identity found"); + return -1; + } + snprintf(pub, sizeof(pub), "%s.pub", ident); + + sock = tcp_connect_blocking(); + if (sock < 0) + return -1; + sess = libssh2_session_init(); + if (!sess) + return -1; + libssh2_session_set_blocking(sess, 1); + libssh2_session_set_timeout(sess, 15000); + // Prefer AEAD ciphers; unsupported names are skipped by libssh2. + libssh2_session_method_pref(sess, LIBSSH2_METHOD_CRYPT_CS, + "aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com,aes128-ctr,aes256-ctr"); + libssh2_session_method_pref(sess, LIBSSH2_METHOD_CRYPT_SC, + "aes128-gcm@openssh.com,aes256-gcm@openssh.com,chacha20-poly1305@openssh.com,aes128-ctr,aes256-ctr"); + + if (libssh2_session_handshake(sess, sock) != 0) { + char *msg; + libssh2_session_last_error(sess, &msg, NULL, 0); + log_err("SSH handshake failed: %s", msg); + return -1; + } + if (verify_host_key() < 0) + return -1; + + int rc = libssh2_userauth_publickey_fromfile_ex(sess, cfg->ssh_user, (unsigned)strlen(cfg->ssh_user), + access(pub, R_OK) == 0 ? pub : NULL, ident, + cfg->identity_pass[0] ? cfg->identity_pass : NULL); + if (rc != 0) { + char *msg; + libssh2_session_last_error(sess, &msg, NULL, 0); + log_err("authentication with %s failed: %s (is the key added in the web UI?)", ident, msg); + return -1; + } + + LIBSSH2_CHANNEL *ch = libssh2_channel_open_session(sess); + if (!ch || libssh2_channel_exec(ch, "patchbay") != 0) { + log_err("cannot open control channel"); + return -1; + } + libssh2_channel_handle_extended_data2(ch, LIBSSH2_CHANNEL_EXTENDED_DATA_IGNORE); + ctl = cc_new(K_CTL, -1); + if (!ctl) + return -1; + ctl->ch = ch; + ctl->state = S_ACTIVE; + + libssh2_keepalive_config(sess, 1, 30); + libssh2_session_set_blocking(sess, 0); + log_info("connected to %s:%d as %s", cfg->target_host, cfg->target_port, cfg->ssh_user); + return 0; +} + +static void session_close(void) +{ + while (ccs) + cc_remove(ccs); // channels are freed with the session + while (csinks) { + struct csink *s = csinks; + csinks = s->next; + sink_close(s); + } + ctl = NULL; + opening = NULL; + pending.op = OP_NONE; + hello_done = 0; + if (sess) { + libssh2_session_set_blocking(sess, 0); + libssh2_session_disconnect(sess, "bye"); + libssh2_session_free(sess); + sess = NULL; + } + if (sock >= 0) { + close(sock); + sock = -1; + } +} + +/* Main loop */ + +// One pass over all SSH-side work. Returns -1 if the session is dead. +static int ssh_pass(int *data) +{ + int rc; + if (pending.op != OP_NONE) { + if ((rc = retry_pending()) != 0) + return rc < 0 ? -1 : 0; + } + if (hello_done && (rc = do_open()) != 0) + return rc < 0 ? -1 : 0; + + for (struct cc *c = ccs, *next; c; c = next) { + next = c->next; + if ((rc = cc_ssh_io(c, data)) != 0) + return rc < 0 ? -1 : 0; + } + + int next_ka; + rc = libssh2_keepalive_send(sess, &next_ka); + if (rc == LIBSSH2_ERROR_EAGAIN && out_blocked()) + pending.op = OP_KEEPALIVE; + else if (rc < 0 && rc != LIBSSH2_ERROR_EAGAIN) + return -1; + return 0; +} + +// True if libssh2 already holds decrypted data for a channel we can accept. +static int ssh_buffered(void) +{ + for (struct cc *c = ccs; c; c = c->next) + if (c->state == S_ACTIVE && c->ch && c->from_ch.len < BUF_LIMIT && libssh2_poll_channel_read(c->ch, 0)) + return 1; + return 0; +} + +static int session_loop(void) +{ + struct pollfd *pfd = NULL; + struct cc **pcc = NULL; + struct csink **psk = NULL; + size_t pcap = 0; + int timeout = 0; + int ret = 0; + + while (!sig_stop) { + size_t need = 1; + for (struct cc *c = ccs; c; c = c->next) + need++; + for (struct csink *s = csinks; s; s = s->next) + need++; + if (need > pcap) { + pcap = need * 2; + pfd = realloc(pfd, pcap * sizeof(*pfd)); + pcc = realloc(pcc, pcap * sizeof(*pcc)); + psk = realloc(psk, pcap * sizeof(*psk)); + if (!pfd || !pcc || !psk) { + ret = -1; + break; + } + } + + size_t n = 0; + int dirs = libssh2_session_block_directions(sess); + pfd[n] = (struct pollfd){ .fd = sock, .events = (short)(POLLIN | (dirs & LIBSSH2_SESSION_BLOCK_OUTBOUND ? POLLOUT : 0)) }; + pcc[n] = NULL; + psk[n++] = NULL; + for (struct csink *s = csinks; s; s = s->next) { + // poll ignores negative fds, so unbound sinks are harmless here. + pfd[n] = (struct pollfd){ .fd = s->fd, .events = POLLIN }; + pcc[n] = NULL; + psk[n++] = s; + } + for (struct cc *c = ccs; c; c = c->next) { + if (c->fd < 0) + continue; + short ev = 0; + if (c->kind == K_SRC_UDP) + ev = POLLIN; + else if (!c->local_eof && c->to_ch.len < BUF_LIMIT) + ev = POLLIN; + if (c->connecting || (c->kind != K_SRC_UDP && c->from_ch.len)) + ev |= POLLOUT; + pfd[n] = (struct pollfd){ .fd = c->fd, .events = ev }; + pcc[n] = c; + psk[n++] = NULL; + } + + int pr = poll(pfd, n, timeout); + if (pr < 0 && errno != EINTR) { + ret = -1; + break; + } + + for (size_t i = 1; i < n; i++) + if (psk[i] && (pfd[i].revents & POLLIN)) + sink_accept(psk[i]); + + // Local reads first so the following SSH pass can send right away. + for (struct cc *c = ccs; c; c = c->next) { + if (c->state == S_CLOSING) + continue; + if (c->kind == K_SINK_TCP || c->kind == K_SRC_TCP) + tcp_local_io(c); + else if (c->kind != K_CTL) + udp_local_io(c); + } + + int data = 0; + if (ssh_pass(&data) < 0) { + char *msg; + libssh2_session_last_error(sess, &msg, NULL, 0); + log_warn("SSH session lost: %s", msg); + ret = -1; + break; + } + if (process_ctl_input() < 0) { + ret = -1; + break; + } + + // Deliver what the SSH pass read. + for (struct cc *c = ccs; c; c = c->next) { + if (c->state == S_CLOSING) + continue; + if (c->kind == K_SINK_TCP || c->kind == K_SRC_TCP) + tcp_local_io(c); + else if (c->kind != K_CTL) + udp_local_io(c); + } + + if (hello_done && now_s() - last_svc >= cfg->services_interval) + send_services(); + if (hello_done && now_s() - last_retry >= 5) { + sinks_retry(); + last_retry = now_s(); + } + + timeout = data || pending.op != OP_NONE || ssh_buffered() ? 0 : 1000; + if (pending.op != OP_NONE) + timeout = 50; // wait for POLLOUT on the socket instead of spinning + // Unsent control or data output also needs another pass soon. + for (struct cc *c = ccs; c && timeout; c = c->next) + if (c->state == S_ACTIVE && c->to_ch.len && !(c->ch && libssh2_channel_window_write(c->ch) == 0)) + timeout = 0; + } + free(pfd); + free(pcc); + free(psk); + return ret; +} + +static void on_signal(int sig) +{ + (void)sig; + sig_stop = 1; +} + +int client_run(const struct pb_config *conf) +{ + cfg = conf; + if (!cfg->target_host[0]) { + log_err("TargetHost is not set"); + return 1; + } + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = on_signal; + sigaction(SIGTERM, &sa, NULL); + sigaction(SIGINT, &sa, NULL); + signal(SIGPIPE, SIG_IGN); + + if (libssh2_init(0) != 0) { + log_err("libssh2_init failed"); + return 1; + } + + int backoff = 1; + while (!sig_stop) { + long started = now_s(); + if (session_open() == 0) + session_loop(); + session_close(); + if (sig_stop) + break; + // A session that lived a while resets the backoff. + if (now_s() - started > 60) + backoff = 1; + log_info("reconnecting in %d s", backoff); + for (int i = 0; i < backoff * 10 && !sig_stop; i++) + usleep(100000); + backoff = backoff < 30 ? backoff * 2 : 30; + } + libssh2_exit(); + return 0; +} diff --git a/backend/src/client.h b/backend/src/client.h new file mode 100644 index 0000000..38b4111 --- /dev/null +++ b/backend/src/client.h @@ -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 diff --git a/backend/src/config.c b/backend/src/config.c new file mode 100644 index 0000000..01e83f6 --- /dev/null +++ b/backend/src/config.c @@ -0,0 +1,129 @@ +#include +#include +#include +#include +#include + +#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; +} diff --git a/backend/src/config.h b/backend/src/config.h new file mode 100644 index 0000000..3c0bddd --- /dev/null +++ b/backend/src/config.h @@ -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 diff --git a/backend/src/db.c b/backend/src/db.c new file mode 100644 index 0000000..67f4e6b --- /dev/null +++ b/backend/src/db.c @@ -0,0 +1,54 @@ +#include +#include + +#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; +} diff --git a/backend/src/db.h b/backend/src/db.h new file mode 100644 index 0000000..7f9f479 --- /dev/null +++ b/backend/src/db.h @@ -0,0 +1,13 @@ +#ifndef PB_DB_H +#define PB_DB_H + +#include + +// 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 diff --git a/backend/src/hub.c b/backend/src/hub.c new file mode 100644 index 0000000..a19aa8e --- /dev/null +++ b/backend/src/hub.c @@ -0,0 +1,1724 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "db.h" +#include "hub.h" +#include "json.h" +#include "log.h" +#include "net.h" +#include "proto.h" +#include "services.h" +#include "stats.h" + +/* Types */ + +enum ev_kind { EV_HUBSOCK, EV_DATAPORT, EV_APISOCK, EV_CTL, EV_PEND, EV_API, EV_TSINK, EV_CONN_A, EV_CONN_B }; + +// Every epoll-registered fd has one of these; data.ptr points at it. +struct ev { + int kind; + int fd; + uint32_t mask; + int registered; + void *owner; +}; + +enum node_type { + NODE_NONE, NODE_CLIENT_SOURCE, NODE_CLIENT_SINK, NODE_PUBLIC_SINK, NODE_SPLITTER, + NODE_TUNNEL_SOURCE, NODE_TUNNEL_SINK, +}; + +// A sink resolved back to its source. Host id 0 means the target itself. +struct route { + int sink_id; + int sink_type; + int sink_client; + char bind[64]; + char sink_iface[IFNAMSIZ]; + int port; + int proto; + int src_client; + char src_host[256]; + char src_iface[IFNAMSIZ]; + int src_port; + char err[128]; // patch problem; the route is unusable + char state_err[128]; // runtime problem (bind failed, interface missing) +}; + +// Control connection of one client (via the relay forced command). +struct ctl { + struct ev ev; + struct ctl *next; + int client_id; // 0 until the RELAY line arrived + int ready; // client HELLO received + char token[PB_TOKEN_LEN + 1]; + char addr[64]; + char hostname[64]; + struct buf in, out; + long connected, last_rx, last_ping; + struct svc_entry *svc; + int nsvc, capsvc, in_svc; + struct iface_entry *ifs; + int nifs, capifs, in_ifs; + int dead; +}; + +// Listener on the target: public sink or tunnel sink of the target. +struct tsink { + struct ev ev; + struct tsink *next; + int sink_id; + char bind[64]; + char iface[IFNAMSIZ]; + unsigned ifindex; // detects a recreated tun interface + int port; + int proto; + int keep; + int dead; +}; + +// Accepted data channel whose header has not been read yet. +struct pend { + struct ev ev; + struct pend *next; + long deadline; + int dead; +}; + +struct api { + struct ev ev; + struct api *next; + struct buf in; + int dead; +}; + +// One direction of a spliced relay. +struct dir { + int pipe[2]; + size_t fill; + int eof, shut; +}; + +enum { CONN_WAIT, CONN_CONNECTING, CONN_RELAY }; +enum { A_STREAM, A_PUBUDP }; // sink side: socket/channel, or flow on a UDP listener +enum { B_STREAM, B_DGRAM }; // source side: socket/channel, or connected UDP socket + +/* A connection between a sink side (a) and a source side (b). + * + * Streams on both sides (TCP, or UDP framed on two channels) are spliced. + * Otherwise (UDP with a raw datagram end) the datagram engine (dg) moves + * single datagrams and frames/deframes them for stream ends. + */ +struct conn { + struct conn *next; + int id; + int sink_id; + int src_client; + int proto; + int state; + int a_kind, b_kind; + int dg; + long deadline, last_act; + struct sink_stats *st; + struct ev a, b; + struct dir ab, ba; // ab = towards the source ("in") + struct tsink *ts; // A_PUBUDP: listener to answer through + struct sockaddr_storage peer; + socklen_t peerlen; + struct buf ain, aout, bin, bout; + int dead; +}; + +/* State */ + +static const struct pb_config *cfg; +static sqlite3 *db; +static int epfd; +static uid_t relay_uid = (uid_t)-1; +static struct ev ev_hubsock, ev_dataport, ev_apisock; + +static struct ctl *ctls; +static struct tsink *tsinks; +static struct pend *pends; +static struct api *apis; +static struct conn *conns; +static struct route *routes; +static int nroutes; +static int next_conn_id = 1; +static size_t pipe_cap = 65536; + +static volatile sig_atomic_t sig_stop, sig_reload; + +#define UDP_IDLE 120 +#define WAIT_TIMEOUT 15 +#define PEND_TIMEOUT 10 +#define CTL_TIMEOUT 90 +#define UDP_QUEUE_MAX (1 << 20) +#define SINK_RETRY 5 + +static long now_s(void) +{ + return (long)time(NULL); +} + +static const char *iface_field(const char *iface) +{ + return iface[0] ? iface : "-"; +} + +/* Epoll helpers */ + +static void ev_set(struct ev *e, uint32_t mask) +{ + if (e->fd < 0) + return; + struct epoll_event ee = { .events = mask, .data.ptr = e }; + if (!e->registered) { + if (epoll_ctl(epfd, EPOLL_CTL_ADD, e->fd, &ee) == 0) + e->registered = 1; + } else if (mask != e->mask) { + epoll_ctl(epfd, EPOLL_CTL_MOD, e->fd, &ee); + } + e->mask = mask; +} + +static void ev_close(struct ev *e) +{ + if (e->fd < 0) + return; + if (e->registered) + epoll_ctl(epfd, EPOLL_CTL_DEL, e->fd, NULL); + close(e->fd); + e->fd = -1; + e->registered = 0; +} + +static void ev_init(struct ev *e, int kind, int fd, void *owner) +{ + e->kind = kind; + e->fd = fd; + e->mask = 0; + e->registered = 0; + e->owner = owner; +} + +/* Routes */ + +static struct route *route_find(int sink_id) +{ + for (int i = 0; i < nroutes; i++) + if (routes[i].sink_id == sink_id) + return &routes[i]; + return NULL; +} + +static int route_on_target(const struct route *r) +{ + return r->sink_type == NODE_PUBLIC_SINK || (r->sink_type == NODE_TUNNEL_SINK && !r->sink_client); +} + +static int route_on_client(const struct route *r, int client_id) +{ + return (r->sink_type == NODE_CLIENT_SINK || r->sink_type == NODE_TUNNEL_SINK) && + r->sink_client && r->sink_client == client_id; +} + +static struct ctl *ctl_by_client(int client_id) +{ + for (struct ctl *c = ctls; c; c = c->next) + if (!c->dead && c->ready && c->client_id == client_id) + return c; + return NULL; +} + +static struct ctl *ctl_by_token(const char *token) +{ + for (struct ctl *c = ctls; c; c = c->next) + if (!c->dead && c->ready && !strcmp(c->token, token)) + return c; + return NULL; +} + +/* Control connections */ + +static void ctl_flush(struct ctl *c) +{ + while (c->out.len) { + ssize_t n = write(c->ev.fd, c->out.data, c->out.len); + if (n < 0) { + if (errno != EAGAIN) + c->dead = 1; + break; + } + buf_consume(&c->out, (size_t)n); + } + if (!c->dead) + ev_set(&c->ev, EPOLLIN | (c->out.len ? EPOLLOUT : 0)); +} + +static void ctl_send(struct ctl *c, const char *fmt, ...) __attribute__((format(printf, 2, 3))); +static void ctl_send(struct ctl *c, const char *fmt, ...) +{ + char line[PB_MAX_LINE]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(line, sizeof(line) - 1, fmt, ap); + va_end(ap); + if (n < 0 || (size_t)n >= sizeof(line) - 1) + return; + line[n++] = '\n'; + buf_append(&c->out, line, (size_t)n); + ctl_flush(c); +} + +static void ctl_push_sinks(struct ctl *c) +{ + ctl_send(c, "SINKS-BEGIN"); + for (int i = 0; i < nroutes; i++) { + struct route *r = &routes[i]; + // Invalid routes are not pushed so the client does not bind a dead port. + if (!route_on_client(r, c->client_id) || r->err[0]) + continue; + ctl_send(c, "SINK %d %s %s %d %s", r->sink_id, proto_name(r->proto), r->bind, r->port, + iface_field(r->sink_iface)); + } + ctl_send(c, "SINKS-END"); +} + +static void store_services(int client_id, struct svc_entry *svc, int n) +{ + sqlite3_stmt *st; + db_exec(db, "BEGIN"); + if (sqlite3_prepare_v2(db, "DELETE FROM services WHERE client_id = ?", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int(st, 1, client_id); + sqlite3_step(st); + sqlite3_finalize(st); + } + if (sqlite3_prepare_v2(db, "INSERT INTO services (client_id, proto, addr, port, pid, process, updated) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", -1, &st, NULL) == SQLITE_OK) { + for (int i = 0; i < n; i++) { + struct svc_entry *e = &svc[i]; + sqlite3_reset(st); + sqlite3_bind_int(st, 1, client_id); + sqlite3_bind_text(st, 2, proto_name(e->proto), -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, e->addr, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 4, e->port); + sqlite3_bind_int(st, 5, e->pid); + sqlite3_bind_text(st, 6, e->process, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 7, now_s()); + sqlite3_step(st); + } + sqlite3_finalize(st); + } + db_exec(db, "COMMIT"); +} + +static void store_ifaces(int client_id, struct iface_entry *ifs, int n) +{ + sqlite3_stmt *st; + db_exec(db, "BEGIN"); + if (sqlite3_prepare_v2(db, "DELETE FROM interfaces WHERE client_id = ?", -1, &st, NULL) == SQLITE_OK) { + sqlite3_bind_int(st, 1, client_id); + sqlite3_step(st); + sqlite3_finalize(st); + } + if (sqlite3_prepare_v2(db, "INSERT INTO interfaces (client_id, name, addr, updated) VALUES (?, ?, ?, ?)", + -1, &st, NULL) == SQLITE_OK) { + for (int i = 0; i < n; i++) { + sqlite3_reset(st); + sqlite3_bind_int(st, 1, client_id); + sqlite3_bind_text(st, 2, ifs[i].name, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, ifs[i].addr, -1, SQLITE_STATIC); + sqlite3_bind_int64(st, 4, now_s()); + sqlite3_step(st); + } + sqlite3_finalize(st); + } + db_exec(db, "COMMIT"); +} + +static void ctl_touch_db(struct ctl *c) +{ + sqlite3_stmt *st; + if (sqlite3_prepare_v2(db, "UPDATE clients SET last_seen = ?, last_addr = ?, hostname = ? WHERE id = ?", + -1, &st, NULL) != SQLITE_OK) + return; + sqlite3_bind_int64(st, 1, now_s()); + sqlite3_bind_text(st, 2, c->addr, -1, SQLITE_STATIC); + sqlite3_bind_text(st, 3, c->hostname, -1, SQLITE_STATIC); + sqlite3_bind_int(st, 4, c->client_id); + sqlite3_step(st); + sqlite3_finalize(st); +} + +static int client_exists(int client_id) +{ + sqlite3_stmt *st; + int found = 0; + if (sqlite3_prepare_v2(db, "SELECT 1 FROM clients WHERE id = ?", -1, &st, NULL) != SQLITE_OK) + return 0; + sqlite3_bind_int(st, 1, client_id); + found = sqlite3_step(st) == SQLITE_ROW; + sqlite3_finalize(st); + return found; +} + +// Grows a dynamic array by one element; returns the new slot or NULL. +static void *grow(void **arr, int *n, int *cap, size_t sz) +{ + if (*n == *cap) { + int nc = *cap ? *cap * 2 : 16; + void *p = realloc(*arr, (size_t)nc * sz); + if (!p) + return NULL; + *arr = p; + *cap = nc; + } + char *slot = (char *)*arr + (size_t)(*n)++ * sz; + memset(slot, 0, sz); + return slot; +} + +static void ctl_line(struct ctl *c, char *line) +{ + char *f[PB_MAX_FIELDS]; + int n = line_split(line, f, PB_MAX_FIELDS); + if (n == 0) + return; + + if (!c->client_id) { + // First line comes from the relay process, which sshd started with the + // client id from authorized_keys, so it can be trusted. + if (n < 2 || strcmp(f[0], "RELAY") || (c->client_id = atoi(f[1])) <= 0 || !client_exists(c->client_id)) { + log_warn("control: bad relay greeting"); + c->dead = 1; + return; + } + snprintf(c->addr, sizeof(c->addr), "%s", n > 2 ? f[2] : "-"); + // Only one control connection per client; the newest one wins. + for (struct ctl *o = ctls; o; o = o->next) + if (o != c && o->client_id == c->client_id) + o->dead = 1; + if (random_token(c->token, sizeof(c->token)) < 0) { + c->dead = 1; + return; + } + ctl_send(c, "HELLO %s %d", c->token, cfg->hub_port); + return; + } + + if (!strcmp(f[0], "HELLO") && n >= 3) { + c->ready = 1; + snprintf(c->hostname, sizeof(c->hostname), "%s", f[2]); + log_info("client %d (%s) connected from %s, version %s", c->client_id, c->hostname, c->addr, f[1]); + ctl_touch_db(c); + ctl_push_sinks(c); + ctl_send(c, "SVC-REQ"); + } else if (!c->ready) { + c->dead = 1; + } else if (!strcmp(f[0], "SVC-BEGIN")) { + c->nsvc = 0; + c->in_svc = 1; + } else if (!strcmp(f[0], "SVC") && n >= 6 && c->in_svc) { + struct svc_entry *e = grow((void **)&c->svc, &c->nsvc, &c->capsvc, sizeof(*e)); + if (!e) + return; + e->proto = proto_parse(f[1]) == PROTO_UDP ? PROTO_UDP : PROTO_TCP; + snprintf(e->addr, sizeof(e->addr), "%s", f[2]); + e->port = atoi(f[3]); + e->pid = atoi(f[4]); + snprintf(e->process, sizeof(e->process), "%s", f[5]); + } else if (!strcmp(f[0], "SVC-END") && c->in_svc) { + c->in_svc = 0; + store_services(c->client_id, c->svc, c->nsvc); + } else if (!strcmp(f[0], "IF-BEGIN")) { + c->nifs = 0; + c->in_ifs = 1; + } else if (!strcmp(f[0], "IF") && n >= 3 && c->in_ifs) { + struct iface_entry *e = grow((void **)&c->ifs, &c->nifs, &c->capifs, sizeof(*e)); + if (!e) + return; + snprintf(e->name, sizeof(e->name), "%s", f[1]); + snprintf(e->addr, sizeof(e->addr), "%s", f[2]); + } else if (!strcmp(f[0], "IF-END") && c->in_ifs) { + c->in_ifs = 0; + store_ifaces(c->client_id, c->ifs, c->nifs); + } else if (!strcmp(f[0], "SINKSTATE") && n >= 3) { + struct route *r = route_find(atoi(f[1])); + if (r && route_on_client(r, c->client_id)) { + if (!strcmp(f[2], "ok")) + r->state_err[0] = '\0'; + else + snprintf(r->state_err, sizeof(r->state_err), "on client: %s", n > 3 ? f[3] : "bind failed"); + } + } else if (!strcmp(f[0], "PONG")) { + // last_rx already updated + } else { + log_debug("client %d: unknown message %s", c->client_id, f[0]); + } +} + +static void ctl_read(struct ctl *c) +{ + char tmp[16384]; + for (;;) { + ssize_t n = read(c->ev.fd, tmp, sizeof(tmp)); + if (n == 0 || (n < 0 && errno != EAGAIN)) { + c->dead = 1; + return; + } + if (n < 0) + break; + buf_append(&c->in, tmp, (size_t)n); + c->last_rx = now_s(); + } + char line[PB_MAX_LINE]; + int rc; + while (!c->dead && (rc = line_next(&c->in, line, sizeof(line))) == 1) + ctl_line(c, line); + if (rc < 0) + c->dead = 1; +} + +/* Connections: common */ + +static void dir_init(struct dir *d) +{ + d->pipe[0] = d->pipe[1] = -1; + d->fill = 0; + d->eof = d->shut = 0; +} + +static int dir_open(struct dir *d) +{ + if (pipe2(d->pipe, O_NONBLOCK | O_CLOEXEC) < 0) + return -1; + fcntl(d->pipe[1], F_SETPIPE_SZ, (int)pipe_cap); + return 0; +} + +static void dir_close(struct dir *d) +{ + if (d->pipe[0] >= 0) + close(d->pipe[0]); + if (d->pipe[1] >= 0) + close(d->pipe[1]); + dir_init(d); +} + +static struct conn *conn_new(struct route *r, int a_kind) +{ + struct conn *c = calloc(1, sizeof(*c)); + if (!c) + return NULL; + c->id = next_conn_id++; + if (next_conn_id <= 0) + next_conn_id = 1; + c->sink_id = r->sink_id; + c->src_client = r->src_client; + c->proto = r->proto; + c->state = CONN_WAIT; + c->a_kind = a_kind; + c->b_kind = B_STREAM; + c->deadline = now_s() + WAIT_TIMEOUT; + c->last_act = now_s(); + c->st = stats_get(r->sink_id); + ev_init(&c->a, EV_CONN_A, -1, c); + ev_init(&c->b, EV_CONN_B, -1, c); + dir_init(&c->ab); + dir_init(&c->ba); + buf_init(&c->ain); + buf_init(&c->aout); + buf_init(&c->bin); + buf_init(&c->bout); + c->next = conns; + conns = c; + if (c->st) + stats_conn_open(c->st); + return c; +} + +static void conn_kill(struct conn *c) +{ + if (c->dead) + return; + c->dead = 1; + ev_close(&c->a); + ev_close(&c->b); + dir_close(&c->ab); + dir_close(&c->ba); + if (c->st) + stats_conn_close(c->st); +} + +/* Connections: datagram engine */ + +static void dg_interest(struct conn *c) +{ + if (c->dead) + return; + if (c->a_kind == A_STREAM) + ev_set(&c->a, EPOLLIN | (c->aout.len ? EPOLLOUT : 0)); + if (c->state == CONN_RELAY) + ev_set(&c->b, EPOLLIN | (c->b_kind == B_STREAM && c->bout.len ? EPOLLOUT : 0)); +} + +static int flush_buf(int fd, struct buf *b) +{ + while (b->len && fd >= 0) { + ssize_t n = write(fd, b->data, b->len); + if (n < 0) + return errno == EAGAIN ? 0 : -1; + buf_consume(b, (size_t)n); + } + return 0; +} + +static void dg_flush(struct conn *c) +{ + if (flush_buf(c->a.fd, &c->aout) < 0 || (c->state == CONN_RELAY && flush_buf(c->b.fd, &c->bout) < 0)) { + conn_kill(c); + return; + } + dg_interest(c); +} + +// One datagram from the peer towards the source. +static void dg_to_b(struct conn *c, const char *p, size_t n) +{ + c->last_act = now_s(); + if (c->st) + stats_add(c->st, n, 0); + if (c->b_kind == B_DGRAM) { + if (c->state == CONN_RELAY) + send(c->b.fd, p, n, MSG_DONTWAIT); + return; + } + // Like a real UDP path, drop when the tunnel cannot keep up. + if (c->bout.len < UDP_QUEUE_MAX) + udp_frame_append(&c->bout, p, n); +} + +// One datagram from the source back to the peer. +static void dg_to_a(struct conn *c, const char *p, size_t n) +{ + c->last_act = now_s(); + if (c->st) + stats_add(c->st, 0, n); + if (c->a_kind == A_PUBUDP) { + if (c->ts && c->ts->ev.fd >= 0) + sendto(c->ts->ev.fd, p, n, MSG_DONTWAIT, (struct sockaddr *)&c->peer, c->peerlen); + } else if (c->aout.len < UDP_QUEUE_MAX) { + udp_frame_append(&c->aout, p, n); + } +} + +// Reads a framed stream into in and hands every complete datagram to fn. +static int read_frames(int fd, struct buf *in, struct conn *c, void (*fn)(struct conn *, const char *, size_t)) +{ + char tmp[65536]; + for (;;) { + ssize_t n = read(fd, tmp, sizeof(tmp)); + if (n == 0 || (n < 0 && errno != EAGAIN)) + return -1; + if (n < 0) + break; + buf_append(in, tmp, (size_t)n); + } + const char *payload; + int len; + while ((len = udp_frame_peek(in, &payload)) != 0) { + size_t plen = len == -2 ? 0 : (size_t)len; + fn(c, payload, plen); + buf_consume(in, plen + 2); + } + return 0; +} + +static void dg_event(struct conn *c, int side_b, uint32_t events) +{ + if (events & (EPOLLIN | EPOLLHUP | EPOLLERR)) { + if (!side_b) { + if (read_frames(c->a.fd, &c->ain, c, dg_to_b) < 0) { + conn_kill(c); + return; + } + } else if (c->b_kind == B_STREAM) { + if (read_frames(c->b.fd, &c->bin, c, dg_to_a) < 0) { + conn_kill(c); + return; + } + } else { + char tmp[PB_UDP_MAX]; + for (int i = 0; i < 256; i++) { + ssize_t n = recv(c->b.fd, tmp, sizeof(tmp), MSG_DONTWAIT); + // ICMP errors (ECONNREFUSED) on a connected UDP socket are not fatal. + if (n < 0) + break; + dg_to_a(c, tmp, (size_t)n); + } + } + } + dg_flush(c); +} + +/* Connections: splice relay */ + +// Splices one direction until both ends would block. +static int pump(struct conn *c, struct dir *d, int src, int dst, int is_in) +{ + for (;;) { + int progress = 0; + if (!d->eof && d->fill == 0) { + ssize_t n = splice(src, NULL, d->pipe[1], NULL, pipe_cap, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); + if (n > 0) { + d->fill += (size_t)n; + progress = 1; + } else if (n == 0) { + d->eof = 1; + } else if (errno != EAGAIN) { + return -1; + } + } + if (d->fill > 0) { + ssize_t m = splice(d->pipe[0], NULL, dst, NULL, d->fill, SPLICE_F_MOVE | SPLICE_F_NONBLOCK); + if (m > 0) { + d->fill -= (size_t)m; + if (c->st) + stats_add(c->st, is_in ? (uint64_t)m : 0, is_in ? 0 : (uint64_t)m); + progress = 1; + } else if (m < 0 && errno != EAGAIN) { + return -1; + } + } + if (!progress) + break; + } + if (d->eof && d->fill == 0 && !d->shut) { + shutdown(dst, SHUT_WR); + d->shut = 1; + } + return 0; +} + +static void conn_interest(struct conn *c) +{ + if (c->dead) + return; + if (c->dg) { + dg_interest(c); + return; + } + if (c->state == CONN_CONNECTING) { + ev_set(&c->a, 0); + ev_set(&c->b, EPOLLOUT); + return; + } + if (c->state == CONN_WAIT) { + ev_set(&c->a, 0); // only errors until the source side exists + return; + } + ev_set(&c->a, (!c->ab.eof && !c->ab.fill ? EPOLLIN : 0) | (c->ba.fill ? EPOLLOUT : 0)); + ev_set(&c->b, (!c->ba.eof && !c->ba.fill ? EPOLLIN : 0) | (c->ab.fill ? EPOLLOUT : 0)); +} + +static void conn_relay(struct conn *c) +{ + if (pump(c, &c->ab, c->a.fd, c->b.fd, 1) < 0 || pump(c, &c->ba, c->b.fd, c->a.fd, 0) < 0) { + conn_kill(c); + return; + } + if (c->ab.shut && c->ba.shut) { + conn_kill(c); + return; + } + conn_interest(c); +} + +// Both sides exist: start moving data. +static void conn_start(struct conn *c) +{ + c->state = CONN_RELAY; + c->last_act = now_s(); + if (c->dg) { + dg_flush(c); + return; + } + if (dir_open(&c->ab) < 0 || dir_open(&c->ba) < 0) { + conn_kill(c); + return; + } + conn_relay(c); +} + +static void conn_set_mode(struct conn *c) +{ + c->dg = c->proto == PROTO_UDP && (c->a_kind == A_PUBUDP || c->b_kind == B_DGRAM); +} + +// Data channel from the source client arrived for a waiting connection. +static void conn_attach_channel(struct conn *c, int fd) +{ + ev_init(&c->b, EV_CONN_B, fd, c); + c->b_kind = B_STREAM; + conn_set_mode(c); + conn_start(c); +} + +// Opens the source side: OPEN to the source client, or a direct socket when +// the source is a tunnel source of the target. +static int conn_request_source(struct conn *c, struct route *r) +{ + if (r->src_client) { + struct ctl *src = ctl_by_client(r->src_client); + if (!src) + return -1; + conn_set_mode(c); + ctl_send(src, "OPEN %d %s %s %d %s", c->id, proto_name(r->proto), r->src_host, r->src_port, + iface_field(r->src_iface)); + return 0; + } + + int inprog = 0; + int fd = net_connect(r->src_host, r->src_port, r->proto, r->src_iface, &inprog); + if (fd < 0) { + log_debug("sink %d: connect %s:%d failed: %s", r->sink_id, r->src_host, r->src_port, strerror(errno)); + return -1; + } + ev_init(&c->b, EV_CONN_B, fd, c); + c->b_kind = r->proto == PROTO_UDP ? B_DGRAM : B_STREAM; + conn_set_mode(c); + if (inprog) { + c->state = CONN_CONNECTING; + conn_interest(c); + } else { + conn_start(c); + } + return 0; +} + +static void conn_connected(struct conn *c) +{ + int err = 0; + socklen_t l = sizeof(err); + getsockopt(c->b.fd, SOL_SOCKET, SO_ERROR, &err, &l); + if (err) { + log_debug("sink %d: source connect failed: %s", c->sink_id, strerror(err)); + conn_kill(c); + return; + } + conn_start(c); +} + +/* Target sinks */ + +static void tsink_accept_tcp(struct tsink *ts) +{ + for (;;) { + int fd = accept4(ts->ev.fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd < 0) + return; + struct route *r = route_find(ts->sink_id); + if (!r || r->err[0]) { + close(fd); + continue; + } + tune_stream(fd); + struct conn *c = conn_new(r, A_STREAM); + if (!c) { + close(fd); + continue; + } + ev_init(&c->a, EV_CONN_A, fd, c); + if (conn_request_source(c, r) < 0) { + log_debug("sink %d: source unavailable", ts->sink_id); + conn_kill(c); + continue; + } + conn_interest(c); + } +} + +static void tsink_recv_udp(struct tsink *ts) +{ + char tmp[PB_UDP_MAX]; + for (int burst = 0; burst < 256; burst++) { + struct sockaddr_storage peer; + socklen_t plen = sizeof(peer); + ssize_t n = recvfrom(ts->ev.fd, tmp, sizeof(tmp), MSG_DONTWAIT, (struct sockaddr *)&peer, &plen); + if (n < 0) + return; + struct route *r = route_find(ts->sink_id); + if (!r || r->err[0]) + continue; + + struct conn *c; + for (c = conns; c; c = c->next) + if (!c->dead && c->a_kind == A_PUBUDP && c->ts == ts && c->peerlen == plen && !memcmp(&c->peer, &peer, plen)) + break; + if (!c) { + c = conn_new(r, A_PUBUDP); + if (!c) + continue; + c->ts = ts; + c->peer = peer; + c->peerlen = plen; + if (conn_request_source(c, r) < 0) { + conn_kill(c); + continue; + } + } + dg_to_b(c, tmp, (size_t)n); + if (c->state == CONN_RELAY) + dg_flush(c); + } +} + +/* Data channel headers */ + +static void pend_read(struct pend *p) +{ + char hdr[160]; + ssize_t n = recv(p->ev.fd, hdr, sizeof(hdr) - 1, MSG_PEEK); + if (n == 0 || (n < 0 && errno != EAGAIN)) { + p->dead = 1; + return; + } + if (n < 0) + return; + hdr[n] = '\0'; + char *nl = memchr(hdr, '\n', (size_t)n); + if (!nl) { + if (n >= (ssize_t)sizeof(hdr) - 1) + p->dead = 1; + return; + } + // Consume exactly the header; stream data after it stays in the socket. + size_t hlen = (size_t)(nl - hdr) + 1; + if (recv(p->ev.fd, hdr, hlen, 0) != (ssize_t)hlen) { + p->dead = 1; + return; + } + hdr[hlen - 1] = '\0'; + + char *f[PB_MAX_FIELDS]; + int nf = line_split(hdr, f, PB_MAX_FIELDS); + struct ctl *owner = nf == 4 && !strcmp(f[0], "PB1") ? ctl_by_token(f[1]) : NULL; + p->dead = 1; // the fd is handed over or closed below + if (!owner) { + log_warn("data channel with invalid header rejected"); + return; + } + + int fd = p->ev.fd; + epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); + p->ev.registered = 0; + p->ev.fd = -1; + + if (!strcmp(f[2], "OPEN")) { + int id = atoi(f[3]); + for (struct conn *c = conns; c; c = c->next) { + if (!c->dead && c->id == id && c->state == CONN_WAIT && c->src_client == owner->client_id) { + conn_attach_channel(c, fd); + return; + } + } + log_debug("data channel for unknown connection %d", id); + close(fd); + } else if (!strcmp(f[2], "SINK")) { + struct route *r = route_find(atoi(f[3])); + if (!r || !route_on_client(r, owner->client_id) || r->err[0]) { + close(fd); + return; + } + struct conn *c = conn_new(r, A_STREAM); + if (!c) { + close(fd); + return; + } + ev_init(&c->a, EV_CONN_A, fd, c); + if (conn_request_source(c, r) < 0) { + conn_kill(c); + return; + } + conn_interest(c); + } else { + close(fd); + } +} + +/* Patch graph */ + +struct gnode { + int id, type, client_id, port, proto, input; + char host[256]; + char iface[IFNAMSIZ]; +}; + +static int node_type_parse(const char *s) +{ + if (!strcmp(s, "client_source")) return NODE_CLIENT_SOURCE; + if (!strcmp(s, "client_sink")) return NODE_CLIENT_SINK; + if (!strcmp(s, "public_sink")) return NODE_PUBLIC_SINK; + if (!strcmp(s, "splitter")) return NODE_SPLITTER; + if (!strcmp(s, "tunnel_source")) return NODE_TUNNEL_SOURCE; + if (!strcmp(s, "tunnel_sink")) return NODE_TUNNEL_SINK; + return NODE_NONE; +} + +static struct gnode *gnode_find(struct gnode *g, int n, int id) +{ + for (int i = 0; i < n; i++) + if (g[i].id == id) + return &g[i]; + return NULL; +} + +static int host_empty(const char *h) +{ + return !h[0] || !strcmp(h, "-"); +} + +static void route_resolve(struct route *r, struct gnode *sink, struct gnode *g, int n) +{ + struct gnode *cur = sink; + for (int depth = 0; depth < 64; depth++) { + if (!cur->input) { + snprintf(r->err, sizeof(r->err), "not connected"); + return; + } + cur = gnode_find(g, n, cur->input); + if (!cur) { + snprintf(r->err, sizeof(r->err), "broken link"); + return; + } + if (cur->type == NODE_CLIENT_SOURCE || cur->type == NODE_TUNNEL_SOURCE) { + int tunnel = cur->type == NODE_TUNNEL_SOURCE; + if (!tunnel && !cur->client_id) + snprintf(r->err, sizeof(r->err), "source has no client"); + else if (cur->port <= 0 || cur->port > 65535) + snprintf(r->err, sizeof(r->err), "source port invalid"); + else if (cur->proto != r->proto) + snprintf(r->err, sizeof(r->err), "protocol mismatch"); + else if (tunnel && !cur->iface[0]) + snprintf(r->err, sizeof(r->err), "tunnel source has no interface"); + else if (tunnel && host_empty(cur->host)) + snprintf(r->err, sizeof(r->err), "tunnel source has no peer address"); + else { + r->src_client = cur->client_id; + snprintf(r->src_host, sizeof(r->src_host), "%s", host_empty(cur->host) ? "127.0.0.1" : cur->host); + snprintf(r->src_iface, sizeof(r->src_iface), "%s", tunnel ? cur->iface : ""); + r->src_port = cur->port; + } + return; + } + if (cur->type != NODE_SPLITTER) { + snprintf(r->err, sizeof(r->err), "input is not a source"); + return; + } + } + snprintf(r->err, sizeof(r->err), "loop in patch"); +} + +static int load_graph(void) +{ + struct gnode *g = NULL; + int n = 0, cap = 0; + sqlite3_stmt *st; + const char *sql = "SELECT n.id, n.type, IFNULL(n.client_id, 0), n.host, n.port, n.proto, n.iface, " + "IFNULL((SELECT from_node FROM links WHERE to_node = n.id), 0) FROM nodes n"; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + log_err("graph: %s", sqlite3_errmsg(db)); + return -1; + } + while (sqlite3_step(st) == SQLITE_ROW) { + struct gnode *x = grow((void **)&g, &n, &cap, sizeof(*x)); + if (!x) + break; + x->id = sqlite3_column_int(st, 0); + x->type = node_type_parse((const char *)sqlite3_column_text(st, 1)); + x->client_id = sqlite3_column_int(st, 2); + snprintf(x->host, sizeof(x->host), "%s", (const char *)sqlite3_column_text(st, 3)); + field_sanitise(x->host); + x->port = sqlite3_column_int(st, 4); + x->proto = proto_parse((const char *)sqlite3_column_text(st, 5)) == PROTO_UDP ? PROTO_UDP : PROTO_TCP; + snprintf(x->iface, sizeof(x->iface), "%s", (const char *)sqlite3_column_text(st, 6)); + if (x->iface[0] && !iface_valid(x->iface)) + x->iface[0] = '\0'; + x->input = sqlite3_column_int(st, 7); + } + sqlite3_finalize(st); + + struct route *nr = calloc((size_t)(n ? n : 1), sizeof(*nr)); + int nn = 0; + for (int i = 0; nr && i < n; i++) { + int t = g[i].type; + if (t != NODE_CLIENT_SINK && t != NODE_PUBLIC_SINK && t != NODE_TUNNEL_SINK) + continue; + struct route *r = &nr[nn++]; + r->sink_id = g[i].id; + r->sink_type = t; + r->sink_client = t == NODE_PUBLIC_SINK ? 0 : g[i].client_id; + r->proto = g[i].proto; + r->port = g[i].port; + if (t == NODE_TUNNEL_SINK) { + // Bound to the interface, so any local address of it is accepted. + snprintf(r->bind, sizeof(r->bind), "0.0.0.0"); + snprintf(r->sink_iface, sizeof(r->sink_iface), "%s", g[i].iface); + } else { + snprintf(r->bind, sizeof(r->bind), "%s", !host_empty(g[i].host) ? g[i].host + : (t == NODE_PUBLIC_SINK ? "0.0.0.0" : "127.0.0.1")); + } + if (r->port <= 0 || r->port > 65535) + snprintf(r->err, sizeof(r->err), "sink port invalid"); + else if (t == NODE_CLIENT_SINK && !r->sink_client) + snprintf(r->err, sizeof(r->err), "sink has no client"); + else if (t == NODE_TUNNEL_SINK && !r->sink_iface[0]) + snprintf(r->err, sizeof(r->err), "tunnel sink has no interface"); + else + route_resolve(r, &g[i], g, n); + } + free(g); + if (!nr) + return -1; + + // Runtime errors of client sinks stay until the client reports again. + for (int i = 0; i < nn; i++) { + struct route *old = route_find(nr[i].sink_id); + if (old && old->port == nr[i].port && old->proto == nr[i].proto && !strcmp(old->bind, nr[i].bind) && + !strcmp(old->sink_iface, nr[i].sink_iface) && old->sink_client == nr[i].sink_client) + snprintf(nr[i].state_err, sizeof(nr[i].state_err), "%s", old->state_err); + } + free(routes); + routes = nr; + nroutes = nn; + return 0; +} + +// Opens missing target listeners, closes stale ones. Safe to call repeatedly; +// failed binds (e.g. tun interface not up yet) are retried by the timer. +static void apply_target_sinks(void) +{ + for (struct tsink *ts = tsinks; ts; ts = ts->next) + ts->keep = 0; + + for (int i = 0; i < nroutes; i++) { + struct route *r = &routes[i]; + if (!route_on_target(r) || r->err[0]) + continue; + struct tsink *ts; + for (ts = tsinks; ts; ts = ts->next) + if (!ts->dead && ts->sink_id == r->sink_id && ts->port == r->port && ts->proto == r->proto && + !strcmp(ts->bind, r->bind) && !strcmp(ts->iface, r->sink_iface)) + break; + if (ts) { + ts->keep = 1; + continue; + } + // Close a listener of the same sink with old parameters first so a + // protocol/port swap on the same address does not collide. + for (struct tsink *o = tsinks; o; o = o->next) + if (o->sink_id == r->sink_id && !o->dead) { + ev_close(&o->ev); + o->dead = 1; + } + unsigned idx = 0; + if (r->sink_iface[0] && !(idx = if_nametoindex(r->sink_iface))) { + if (strcmp(r->state_err, "interface not present")) + log_warn("sink %d: interface %s not present", r->sink_id, r->sink_iface); + snprintf(r->state_err, sizeof(r->state_err), "interface not present"); + continue; + } + int fd = net_listen(r->bind, r->port, r->proto, r->sink_iface); + if (fd < 0) { + char msg[128]; + snprintf(msg, sizeof(msg), "bind %s:%d failed: %s", r->bind, r->port, strerror(errno)); + if (strcmp(r->state_err, msg)) + log_warn("sink %d: %s", r->sink_id, msg); + snprintf(r->state_err, sizeof(r->state_err), "%s", msg); + continue; + } + ts = calloc(1, sizeof(*ts)); + if (!ts) { + close(fd); + continue; + } + ts->sink_id = r->sink_id; + snprintf(ts->bind, sizeof(ts->bind), "%s", r->bind); + snprintf(ts->iface, sizeof(ts->iface), "%s", r->sink_iface); + ts->ifindex = idx; + ts->port = r->port; + ts->proto = r->proto; + ts->keep = 1; + ev_init(&ts->ev, EV_TSINK, fd, ts); + ev_set(&ts->ev, EPOLLIN); + ts->next = tsinks; + tsinks = ts; + r->state_err[0] = '\0'; + log_info("sink %d listening on %s %s:%d%s%s", ts->sink_id, proto_name(ts->proto), ts->bind, ts->port, + ts->iface[0] ? " dev " : "", ts->iface); + } + + for (struct tsink *ts = tsinks; ts; ts = ts->next) { + if (ts->keep || ts->dead) + continue; + ev_close(&ts->ev); + ts->dead = 1; + log_info("sink %d closed", ts->sink_id); + } + // UDP flows answer through their listener, so they cannot outlive it. + for (struct conn *c = conns; c; c = c->next) + if (c->a_kind == A_PUBUDP && c->ts && c->ts->dead) + conn_kill(c); +} + +// A tun interface that was removed and recreated gets a new index; sockets +// bound to the old one never see traffic again. +static void check_ifaces(void) +{ + for (struct tsink *ts = tsinks; ts; ts = ts->next) { + if (ts->dead || !ts->iface[0] || if_nametoindex(ts->iface) == ts->ifindex) + continue; + log_info("sink %d: interface %s changed, rebinding", ts->sink_id, ts->iface); + ev_close(&ts->ev); + ts->dead = 1; + } + apply_target_sinks(); +} + +int hub_write_authorized_keys(const struct pb_config *conf, sqlite3 *kdb) +{ + char tmp[300]; + snprintf(tmp, sizeof(tmp), "%s.tmp", conf->authorized_keys); + FILE *f = fopen(tmp, "w"); + if (!f) { + log_err("cannot write %s: %s", tmp, strerror(errno)); + return -1; + } + fchmod(fileno(f), 0644); + fprintf(f, "# Generated by patchbayd from patchbay.db. Do not edit.\n"); + + sqlite3_stmt *st; + if (sqlite3_prepare_v2(kdb, "SELECT id, pubkey FROM clients ORDER BY id", -1, &st, NULL) == SQLITE_OK) { + while (sqlite3_step(st) == SQLITE_ROW) { + int id = sqlite3_column_int(st, 0); + const char *key = (const char *)sqlite3_column_text(st, 1); + // The web frontend validates keys; refuse anything that could inject options. + if (!key || strpbrk(key, "\"\n\r,")) + continue; + fprintf(f, "restrict,port-forwarding,permitopen=\"127.0.0.1:%d\",command=\"%s --relay %d\" %s patchbay-client-%d\n", + conf->hub_port, conf->daemon_path, id, key, id); + } + sqlite3_finalize(st); + } + if (fclose(f) != 0 || rename(tmp, conf->authorized_keys) < 0) { + log_err("cannot replace %s: %s", conf->authorized_keys, strerror(errno)); + unlink(tmp); + return -1; + } + return 0; +} + +static void reload(void) +{ + log_info("reloading patch graph"); + hub_write_authorized_keys(cfg, db); + for (struct ctl *c = ctls; c; c = c->next) + if (c->client_id && !client_exists(c->client_id)) { + log_info("client %d removed, disconnecting", c->client_id); + c->dead = 1; + } + if (load_graph() < 0) + return; + apply_target_sinks(); + for (struct ctl *c = ctls; c; c = c->next) + if (!c->dead && c->ready) + ctl_push_sinks(c); +} + +/* Web API */ + +static void api_status(struct buf *b) +{ + buf_printf(b, "{\"ok\":true,\"clients\":{"); + int first = 1; + for (struct ctl *c = ctls; c; c = c->next) { + if (c->dead || !c->ready) + continue; + buf_printf(b, "%s\"%d\":{\"since\":%ld,\"addr\":", first ? "" : ",", c->client_id, c->connected); + json_str(b, c->addr); + buf_printf(b, ",\"hostname\":"); + json_str(b, c->hostname); + buf_printf(b, "}"); + first = 0; + } + buf_printf(b, "},\"sinks\":{"); + for (int i = 0; i < nroutes; i++) { + struct route *r = &routes[i]; + const char *err = r->err[0] ? r->err : r->state_err; + int online = r->err[0] ? 0 : !r->src_client || ctl_by_client(r->src_client); + buf_printf(b, "%s\"%d\":{\"ok\":%s,\"source_online\":%s,\"error\":", i ? "," : "", r->sink_id, + err[0] ? "false" : "true", online ? "true" : "false"); + json_str(b, err); + buf_printf(b, "}"); + } + buf_printf(b, "}}"); +} + +static void scan_target(void) +{ + struct svc_entry *svc = NULL; + int n = services_scan(&svc); + if (n >= 0) + store_services(0, svc, n); + free(svc); + struct iface_entry *ifs = NULL; + n = ifaces_scan(&ifs); + if (n >= 0) + store_ifaces(0, ifs, n); + free(ifs); +} + +static void api_handle(struct api *a, char *req) +{ + struct buf out; + buf_init(&out); + if (!strcmp(req, "RELOAD")) { + reload(); + buf_printf(&out, "{\"ok\":true}"); + } else if (!strcmp(req, "STATUS")) { + api_status(&out); + } else if (!strcmp(req, "LIVE")) { + buf_printf(&out, "{\"ok\":true,\"sinks\":"); + stats_live_json(&out); + buf_printf(&out, "}"); + } else if (!strcmp(req, "SERVICES")) { + for (struct ctl *c = ctls; c; c = c->next) + if (!c->dead && c->ready) + ctl_send(c, "SVC-REQ"); + scan_target(); + buf_printf(&out, "{\"ok\":true}"); + } else { + buf_printf(&out, "{\"ok\":false,\"error\":\"unknown request\"}"); + } + buf_append(&out, "\n", 1); + + // Responses are small; write them blocking with a timeout. + int fl = fcntl(a->ev.fd, F_GETFL); + fcntl(a->ev.fd, F_SETFL, fl & ~O_NONBLOCK); + struct timeval tv = { .tv_sec = 2 }; + setsockopt(a->ev.fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + size_t off = 0; + while (off < out.len) { + ssize_t n = write(a->ev.fd, out.data + off, out.len - off); + if (n <= 0) + break; + off += (size_t)n; + } + buf_free(&out); + a->dead = 1; +} + +static void api_read(struct api *a) +{ + char tmp[512]; + ssize_t n = read(a->ev.fd, tmp, sizeof(tmp)); + if (n == 0 || (n < 0 && errno != EAGAIN)) { + a->dead = 1; + return; + } + if (n < 0) + return; + buf_append(&a->in, tmp, (size_t)n); + char line[PB_MAX_LINE]; + int rc = line_next(&a->in, line, sizeof(line)); + if (rc == 1) + api_handle(a, line); + else if (rc < 0) + a->dead = 1; +} + +/* Accepting */ + +static void accept_ctl(void) +{ + for (;;) { + int fd = accept4(ev_hubsock.fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd < 0) + return; + struct ucred cred; + socklen_t cl = sizeof(cred); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &cl) < 0 || + (cred.uid != 0 && cred.uid != relay_uid)) { + log_warn("hub socket: rejected peer uid %d", (int)cred.uid); + close(fd); + continue; + } + struct ctl *c = calloc(1, sizeof(*c)); + if (!c) { + close(fd); + continue; + } + ev_init(&c->ev, EV_CTL, fd, c); + buf_init(&c->in); + buf_init(&c->out); + c->connected = c->last_rx = c->last_ping = now_s(); + c->next = ctls; + ctls = c; + ev_set(&c->ev, EPOLLIN); + } +} + +static void accept_data(void) +{ + for (;;) { + int fd = accept4(ev_dataport.fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd < 0) + return; + tune_stream(fd); + struct pend *p = calloc(1, sizeof(*p)); + if (!p) { + close(fd); + continue; + } + ev_init(&p->ev, EV_PEND, fd, p); + p->deadline = now_s() + PEND_TIMEOUT; + p->next = pends; + pends = p; + ev_set(&p->ev, EPOLLIN); + } +} + +static void accept_api(void) +{ + for (;;) { + int fd = accept4(ev_apisock.fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (fd < 0) + return; + struct api *a = calloc(1, sizeof(*a)); + if (!a) { + close(fd); + continue; + } + ev_init(&a->ev, EV_API, fd, a); + buf_init(&a->in); + a->next = apis; + apis = a; + ev_set(&a->ev, EPOLLIN); + } +} + +/* Housekeeping */ + +static void sweep(void) +{ + for (struct ctl **pp = &ctls; *pp;) { + struct ctl *c = *pp; + if (!c->dead) { + pp = &c->next; + continue; + } + if (c->ready) + log_info("client %d disconnected", c->client_id); + // Connections waiting on this client will never get their channel. + for (struct conn *x = conns; x; x = x->next) + if (x->state == CONN_WAIT && x->src_client == c->client_id) + conn_kill(x); + ev_close(&c->ev); + buf_free(&c->in); + buf_free(&c->out); + free(c->svc); + free(c->ifs); + *pp = c->next; + free(c); + } + for (struct pend **pp = &pends; *pp;) { + struct pend *p = *pp; + if (!p->dead) { + pp = &p->next; + continue; + } + ev_close(&p->ev); + *pp = p->next; + free(p); + } + for (struct api **pp = &apis; *pp;) { + struct api *a = *pp; + if (!a->dead) { + pp = &a->next; + continue; + } + ev_close(&a->ev); + buf_free(&a->in); + *pp = a->next; + free(a); + } + for (struct conn **pp = &conns; *pp;) { + struct conn *c = *pp; + if (!c->dead) { + pp = &c->next; + continue; + } + buf_free(&c->ain); + buf_free(&c->aout); + buf_free(&c->bin); + buf_free(&c->bout); + *pp = c->next; + free(c); + } + // Target sinks go last: conns may still reference them above. + for (struct tsink **pp = &tsinks; *pp;) { + struct tsink *ts = *pp; + if (!ts->dead) { + pp = &ts->next; + continue; + } + *pp = ts->next; + free(ts); + } +} + +static void timers(long now) +{ + for (struct pend *p = pends; p; p = p->next) + if (p->deadline < now) + p->dead = 1; + for (struct conn *c = conns; c; c = c->next) { + if (c->dead) + continue; + if ((c->state == CONN_WAIT || c->state == CONN_CONNECTING) && c->deadline < now) + conn_kill(c); + else if (c->proto == PROTO_UDP && c->last_act + UDP_IDLE < now) + conn_kill(c); + } + for (struct ctl *c = ctls; c; c = c->next) { + if (c->dead) + continue; + if (c->last_rx + CTL_TIMEOUT < now) + c->dead = 1; + else if (c->ready && now - c->last_ping >= 30) { + ctl_send(c, "PING"); + c->last_ping = now; + } + } +} + +static void on_signal(int sig) +{ + if (sig == SIGHUP) + sig_reload = 1; + else + sig_stop = 1; +} + +/* Main loop */ + +static int open_listeners(void) +{ + char path[300]; + mkdir(cfg->run_dir, 0755); + + snprintf(path, sizeof(path), "%s/hub.sock", cfg->run_dir); + int fd = net_listen_unix(path, 0660); + if (fd < 0) { + log_err("cannot listen on %s: %s", path, strerror(errno)); + return -1; + } + // The relay runs as the SSH user, so the socket belongs to its group. + struct passwd *pw = getpwnam(cfg->ssh_user); + if (pw) { + relay_uid = pw->pw_uid; + if (chown(path, 0, pw->pw_gid) < 0) + log_warn("chown %s: %s", path, strerror(errno)); + } else { + log_warn("user %s not found; only root may connect to the hub", cfg->ssh_user); + } + ev_init(&ev_hubsock, EV_HUBSOCK, fd, NULL); + ev_set(&ev_hubsock, EPOLLIN); + + snprintf(path, sizeof(path), "%s/api.sock", cfg->run_dir); + fd = net_listen_unix(path, 0600); + if (fd < 0) { + log_err("cannot listen on %s: %s", path, strerror(errno)); + return -1; + } + ev_init(&ev_apisock, EV_APISOCK, fd, NULL); + ev_set(&ev_apisock, EPOLLIN); + + fd = net_listen("127.0.0.1", cfg->hub_port, PROTO_TCP, NULL); + if (fd < 0) { + log_err("cannot listen on 127.0.0.1:%d: %s", cfg->hub_port, strerror(errno)); + return -1; + } + ev_init(&ev_dataport, EV_DATAPORT, fd, NULL); + ev_set(&ev_dataport, EPOLLIN); + return 0; +} + +static void dispatch(struct ev *e, uint32_t events) +{ + switch (e->kind) { + case EV_HUBSOCK: + accept_ctl(); + break; + case EV_DATAPORT: + accept_data(); + break; + case EV_APISOCK: + accept_api(); + break; + case EV_CTL: { + struct ctl *c = e->owner; + if (c->dead) + break; + if (events & (EPOLLIN | EPOLLHUP | EPOLLERR)) + ctl_read(c); + if (!c->dead && (events & EPOLLOUT)) + ctl_flush(c); + break; + } + case EV_PEND: { + struct pend *p = e->owner; + if (!p->dead) + pend_read(p); + break; + } + case EV_API: { + struct api *a = e->owner; + if (!a->dead) + api_read(a); + break; + } + case EV_TSINK: { + struct tsink *ts = e->owner; + if (ts->dead) + break; + if (ts->proto == PROTO_UDP) + tsink_recv_udp(ts); + else + tsink_accept_tcp(ts); + break; + } + case EV_CONN_A: + case EV_CONN_B: { + struct conn *c = e->owner; + int side_b = e->kind == EV_CONN_B; + if (c->dead) + break; + if (c->state == CONN_CONNECTING) { + if (side_b) + conn_connected(c); + else if (events & (EPOLLHUP | EPOLLERR)) + conn_kill(c); + } else if (c->dg) { + dg_event(c, side_b, events); + } else if (c->state == CONN_WAIT) { + if (events & (EPOLLHUP | EPOLLERR)) + conn_kill(c); + } else if (events & EPOLLERR) { + conn_kill(c); + } else { + conn_relay(c); + } + break; + } + } +} + +int hub_run(const struct pb_config *conf) +{ + cfg = conf; + db = db_open(cfg->db_path); + if (!db) + return 1; + + epfd = epoll_create1(EPOLL_CLOEXEC); + if (epfd < 0 || open_listeners() < 0) + return 1; + + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = on_signal; + sigaction(SIGTERM, &sa, NULL); + sigaction(SIGINT, &sa, NULL); + sigaction(SIGHUP, &sa, NULL); + signal(SIGPIPE, SIG_IGN); + + // Bigger pipes mean fewer splice calls per megabyte. + int probe[2]; + if (pipe(probe) == 0) { + int sz = fcntl(probe[1], F_SETPIPE_SZ, 1 << 20); + if (sz > 0) + pipe_cap = (size_t)sz; + close(probe[0]); + close(probe[1]); + } + + reload(); + scan_target(); + log_info("target hub running (sshd port %d, hub port %d)", cfg->ssh_port, cfg->hub_port); + + struct timespec last; + clock_gettime(CLOCK_MONOTONIC, &last); + long last_minute = now_s() / 60, last_svc = now_s(), last_retry = now_s(); + + while (!sig_stop) { + struct epoll_event evs[128]; + int n = epoll_wait(epfd, evs, 128, 1000); + if (n < 0 && errno != EINTR) { + log_err("epoll_wait: %s", strerror(errno)); + break; + } + for (int i = 0; i < n; i++) + dispatch(evs[i].data.ptr, evs[i].events); + + if (sig_reload) { + sig_reload = 0; + reload(); + } + + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + double dt = (double)(ts.tv_sec - last.tv_sec) + (double)(ts.tv_nsec - last.tv_nsec) / 1e9; + if (dt >= 1.0) { + long now = now_s(); + stats_tick_second(dt); + last = ts; + timers(now); + if (now - last_retry >= SINK_RETRY) { + check_ifaces(); + last_retry = now; + } + if (now / 60 != last_minute) { + // Attribute the finished minute to its own timestamp. + stats_flush(db, last_minute * 60); + last_minute = now / 60; + for (struct ctl *c = ctls; c; c = c->next) + if (!c->dead && c->ready) + ctl_touch_db(c); + } + if (now - last_svc >= cfg->services_interval) { + scan_target(); + last_svc = now; + } + } + sweep(); + } + + log_info("shutting down"); + stats_flush(db, now_s()); + sqlite3_close(db); + return 0; +} diff --git a/backend/src/hub.h b/backend/src/hub.h new file mode 100644 index 0000000..1a525da --- /dev/null +++ b/backend/src/hub.h @@ -0,0 +1,14 @@ +#ifndef PB_HUB_H +#define PB_HUB_H + +#include + +#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 diff --git a/backend/src/json.c b/backend/src/json.c new file mode 100644 index 0000000..e6dc1c1 --- /dev/null +++ b/backend/src/json.c @@ -0,0 +1,31 @@ +#include + +#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); +} diff --git a/backend/src/json.h b/backend/src/json.h new file mode 100644 index 0000000..29e28a2 --- /dev/null +++ b/backend/src/json.h @@ -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 diff --git a/backend/src/log.c b/backend/src/log.c new file mode 100644 index 0000000..0d5e22e --- /dev/null +++ b/backend/src/log.c @@ -0,0 +1,33 @@ +#include +#include +#include + +#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); +} diff --git a/backend/src/log.h b/backend/src/log.h new file mode 100644 index 0000000..d7298ef --- /dev/null +++ b/backend/src/log.h @@ -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 diff --git a/backend/src/main.c b/backend/src/main.c new file mode 100644 index 0000000..d2d28b3 --- /dev/null +++ b/backend/src/main.c @@ -0,0 +1,120 @@ +#include +#include +#include +#include + +#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 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); +} diff --git a/backend/src/net.c b/backend/src/net.c new file mode 100644 index 0000000..335f5d6 --- /dev/null +++ b/backend/src/net.c @@ -0,0 +1,216 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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, "?"); + } +} diff --git a/backend/src/net.h b/backend/src/net.h new file mode 100644 index 0000000..5f2aa65 --- /dev/null +++ b/backend/src/net.h @@ -0,0 +1,29 @@ +#ifndef PB_NET_H +#define PB_NET_H + +#include + +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 diff --git a/backend/src/proto.c b/backend/src/proto.c new file mode 100644 index 0000000..29292dc --- /dev/null +++ b/backend/src/proto.c @@ -0,0 +1,152 @@ +#include +#include +#include +#include +#include + +#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; +} diff --git a/backend/src/proto.h b/backend/src/proto.h new file mode 100644 index 0000000..9e3ae99 --- /dev/null +++ b/backend/src/proto.h @@ -0,0 +1,73 @@ +#ifndef PB_PROTO_H +#define PB_PROTO_H + +#include +#include + +/* 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 + * SINKS-BEGIN / SINK / SINKS-END + * OPEN + * SVC-REQ, PING + * client -> hub: HELLO + * SVC-BEGIN / SVC / SVC-END + * IF-BEGIN / IF / IF-END (tun interfaces) + * SINKSTATE + * PONG + * + * 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 SINK \n" (a client sink accepted a + * connection) or "PB1 OPEN \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 diff --git a/backend/src/relay.c b/backend/src/relay.c new file mode 100644 index 0000000..42e1f11 --- /dev/null +++ b/backend/src/relay.c @@ -0,0 +1,66 @@ +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/backend/src/relay.h b/backend/src/relay.h new file mode 100644 index 0000000..dd6e24b --- /dev/null +++ b/backend/src/relay.h @@ -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 diff --git a/backend/src/services.c b/backend/src/services.c new file mode 100644 index 0000000..0328b98 --- /dev/null +++ b/backend/src/services.c @@ -0,0 +1,213 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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; +} diff --git a/backend/src/services.h b/backend/src/services.h new file mode 100644 index 0000000..555d2b1 --- /dev/null +++ b/backend/src/services.h @@ -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 diff --git a/backend/src/stats.c b/backend/src/stats.c new file mode 100644 index 0000000..d1533b2 --- /dev/null +++ b/backend/src/stats.c @@ -0,0 +1,141 @@ +#include +#include + +#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); +} diff --git a/backend/src/stats.h b/backend/src/stats.h new file mode 100644 index 0000000..48e0b7a --- /dev/null +++ b/backend/src/stats.h @@ -0,0 +1,33 @@ +#ifndef PB_STATS_H +#define PB_STATS_H + +#include +#include + +#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); + +// {"": {"in": rate, "out": rate, "active": n, "total_in": .., ...}, ...} +int stats_live_json(struct buf *b); + +#endif diff --git a/backend/tests/test_main.c b/backend/tests/test_main.c new file mode 100644 index 0000000..0f56bd5 --- /dev/null +++ b/backend/tests/test_main.c @@ -0,0 +1,145 @@ +/* Minimal unit test runner: ./test_runner [test_name ...] */ + +#include +#include +#include + +#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; +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..df77eff --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000..90ab17e --- /dev/null +++ b/docker/compose.yml @@ -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: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..67d150f --- /dev/null +++ b/docker/entrypoint.sh @@ -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" < "$CONF" </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 ] diff --git a/examples/patchbay.conf.client b/examples/patchbay.conf.client new file mode 100644 index 0000000..9917014 --- /dev/null +++ b/examples/patchbay.conf.client @@ -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 diff --git a/examples/patchbay.conf.target b/examples/patchbay.conf.target new file mode 100644 index 0000000..8fcef2c --- /dev/null +++ b/examples/patchbay.conf.target @@ -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 diff --git a/init/openrc/patchbay-sshd b/init/openrc/patchbay-sshd new file mode 100755 index 0000000..4478080 --- /dev/null +++ b/init/openrc/patchbay-sshd @@ -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 +} diff --git a/init/openrc/patchbay-web b/init/openrc/patchbay-web new file mode 100755 index 0000000..0547865 --- /dev/null +++ b/init/openrc/patchbay-web @@ -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 +} diff --git a/init/openrc/patchbayd b/init/openrc/patchbayd new file mode 100755 index 0000000..736026e --- /dev/null +++ b/init/openrc/patchbayd @@ -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 +} diff --git a/init/runit/patchbay-sshd/run b/init/runit/patchbay-sshd/run new file mode 100755 index 0000000..58d16e2 --- /dev/null +++ b/init/runit/patchbay-sshd/run @@ -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 diff --git a/init/runit/patchbay-web/run b/init/runit/patchbay-web/run new file mode 100755 index 0000000..4599d56 --- /dev/null +++ b/init/runit/patchbay-web/run @@ -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 diff --git a/init/runit/patchbayd/run b/init/runit/patchbayd/run new file mode 100755 index 0000000..e6a78a7 --- /dev/null +++ b/init/runit/patchbayd/run @@ -0,0 +1,4 @@ +#!/bin/sh +exec 2>&1 +mkdir -p /run/patchbay +exec /usr/local/sbin/patchbayd -c /etc/patchbay/patchbay.conf diff --git a/init/systemd/patchbay-sshd.service b/init/systemd/patchbay-sshd.service new file mode 100644 index 0000000..46345cf --- /dev/null +++ b/init/systemd/patchbay-sshd.service @@ -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 diff --git a/init/systemd/patchbay-web.service b/init/systemd/patchbay-web.service new file mode 100644 index 0000000..7ebd31b --- /dev/null +++ b/init/systemd/patchbay-web.service @@ -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 diff --git a/init/systemd/patchbayd.service b/init/systemd/patchbayd.service new file mode 100644 index 0000000..878557f --- /dev/null +++ b/init/systemd/patchbayd.service @@ -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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ef79c20 --- /dev/null +++ b/install.sh @@ -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" diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..bc43cf0 --- /dev/null +++ b/schema.sql @@ -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'); diff --git a/sshd/patchbay-sshd b/sshd/patchbay-sshd new file mode 100755 index 0000000..3bb0479 --- /dev/null +++ b/sshd/patchbay-sshd @@ -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" diff --git a/sshd/sshd_config.in b/sshd/sshd_config.in new file mode 100644 index 0000000..f375925 --- /dev/null +++ b/sshd/sshd_config.in @@ -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 diff --git a/web/patchbay_web/__init__.py b/web/patchbay_web/__init__.py new file mode 100644 index 0000000..450d7b7 --- /dev/null +++ b/web/patchbay_web/__init__.py @@ -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 diff --git a/web/patchbay_web/__main__.py b/web/patchbay_web/__main__.py new file mode 100644 index 0000000..9e7b732 --- /dev/null +++ b/web/patchbay_web/__main__.py @@ -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()) diff --git a/web/patchbay_web/api.py b/web/patchbay_web/api.py new file mode 100644 index 0000000..d8c6945 --- /dev/null +++ b/web/patchbay_web/api.py @@ -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]) diff --git a/web/patchbay_web/auth.py b/web/patchbay_web/auth.py new file mode 100644 index 0000000..8f3c615 --- /dev/null +++ b/web/patchbay_web/auth.py @@ -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:] diff --git a/web/patchbay_web/conf.py b/web/patchbay_web/conf.py new file mode 100644 index 0000000..0a1a003 --- /dev/null +++ b/web/patchbay_web/conf.py @@ -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 diff --git a/web/patchbay_web/daemon.py b/web/patchbay_web/daemon.py new file mode 100644 index 0000000..6a7a34d --- /dev/null +++ b/web/patchbay_web/daemon.py @@ -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") diff --git a/web/patchbay_web/db.py b/web/patchbay_web/db.py new file mode 100644 index 0000000..447b001 --- /dev/null +++ b/web/patchbay_web/db.py @@ -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()) diff --git a/web/patchbay_web/mailer.py b/web/patchbay_web/mailer.py new file mode 100644 index 0000000..1251ad6 --- /dev/null +++ b/web/patchbay_web/mailer.py @@ -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 diff --git a/web/patchbay_web/security.py b/web/patchbay_web/security.py new file mode 100644 index 0000000..18baa54 --- /dev/null +++ b/web/patchbay_web/security.py @@ -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}" diff --git a/web/patchbay_web/static/common.js b/web/patchbay_web/static/common.js new file mode 100644 index 0000000..7a993df --- /dev/null +++ b/web/patchbay_web/static/common.js @@ -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 }; +})(); diff --git a/web/patchbay_web/static/patch.js b/web/patchbay_web/static/patch.js new file mode 100644 index 0000000..f9193b4 --- /dev/null +++ b/web/patchbay_web/static/patch.js @@ -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(); +})(); diff --git a/web/patchbay_web/static/services.js b/web/patchbay_web/static/services.js new file mode 100644 index 0000000..cc7631d --- /dev/null +++ b/web/patchbay_web/static/services.js @@ -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; + } +}); diff --git a/web/patchbay_web/static/stats.js b/web/patchbay_web/static/stats.js new file mode 100644 index 0000000..b52d10f --- /dev/null +++ b/web/patchbay_web/static/stats.js @@ -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); +})(); diff --git a/web/patchbay_web/static/style.css b/web/patchbay_web/static/style.css new file mode 100644 index 0000000..249d69d --- /dev/null +++ b/web/patchbay_web/static/style.css @@ -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; } +} diff --git a/web/patchbay_web/templates/base.html b/web/patchbay_web/templates/base.html new file mode 100644 index 0000000..5f14ebb --- /dev/null +++ b/web/patchbay_web/templates/base.html @@ -0,0 +1,41 @@ + + + + + + +{% block title %}PatchBay{% endblock %} - PatchBay + + + +{% if user %} +
+ PatchBay + + {% block toolbar %}{% endblock %} +
+ + {{ user.username }}{% if user.sysop %} (sysop){% endif %} + +
+
+{% endif %} +{% with msgs = get_flashed_messages(with_categories=true) %} +{% if msgs %} +
+ {% for cat, msg in msgs %}
{{ msg }}
{% endfor %} +
+{% endif %} +{% endwith %} +
+{% block content %}{% endblock %} +
+{% block scripts %}{% endblock %} + + diff --git a/web/patchbay_web/templates/login.html b/web/patchbay_web/templates/login.html new file mode 100644 index 0000000..214247e --- /dev/null +++ b/web/patchbay_web/templates/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Log in{% endblock %} +{% block bodyclass %}auth{% endblock %} +{% block content %} +
+

PatchBay

+

Log in to manage this gateway.

+ + + + +

A one-time code will be sent to your email address.

+
+{% endblock %} diff --git a/web/patchbay_web/templates/patch.html b/web/patchbay_web/templates/patch.html new file mode 100644 index 0000000..85a2565 --- /dev/null +++ b/web/patchbay_web/templates/patch.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Patch{% endblock %} +{% block mainclass %}editor-main{% endblock %} +{% block toolbar %} +
+ + + +
+{% endblock %} +{% block content %} +
+ +
+
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.
+
+{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/web/patchbay_web/templates/services.html b/web/patchbay_web/templates/services.html new file mode 100644 index 0000000..43b944f --- /dev/null +++ b/web/patchbay_web/templates/services.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Services{% endblock %} +{% block content %} +
+

Services

+ +
+{% if not daemon_ok %}
patchbayd is not reachable; showing the last reported data.
{% endif %} +

Listening TCP ports and bound UDP ports on every host, with the process that owns them. Clients report every few seconds while connected.

+{% for h in hosts %} +
+

{{ h.name }}

+ {% if h.ifaces %} +

Tunnel interfaces: + {% for i in h.ifaces %}{{ i.name }}{% if i.addr != '-' %} {{ i.addr }}{% endif %}{{ ', ' if not loop.last }}{% endfor %} +

+ {% endif %} + {% if h.services %} + + + + {% for s in h.services %} + + + {% endfor %} + +
ProtoAddressPortProcessPID
{{ s.proto | upper }}{{ s.addr }}{{ s.port }}{{ s.process }}{{ s.pid or '-' }}
+

Updated

+ {% else %} +

No data reported yet.

+ {% endif %} +
+{% endfor %} +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/web/patchbay_web/templates/settings.html b/web/patchbay_web/templates/settings.html new file mode 100644 index 0000000..7e976ec --- /dev/null +++ b/web/patchbay_web/templates/settings.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} +{% block title %}Settings{% endblock %} +{% macro csrf() %}{% endmacro %} +{% block content %} +

Settings

+ +
+

My account

+
+ {{ csrf() }} + + + + +
+
+
+ +
+

Clients

+

A machine can only connect once its public key is listed here. On the client, run patchbayd --pubkey and paste the output below.

+ {% if clients %} + + + + {% for c in clients %} + + + + + + + + + {% endfor %} + +
NameHostKeyLast seenAdded by
{{ c.name }}{{ c.hostname or '-' }}{% if c.last_addr %} {{ c.last_addr }}{% endif %}{{ c.pubkey.split(' ')[0] }} ...{{ c.pubkey[-12:] }}{{ c.added_by }} +
+ {{ csrf() }} + +
+
+ {% endif %} +
+ {{ csrf() }} + + +
+
+
+ +{% if user.sysop %} +
+

Users

+

All users can edit the patch and manage clients. Only the sysop ({{ conf.get('sysopuser') }}, defined in patchbay.conf) manages users and server settings.

+ {% for u in users %} +
+
+ {{ csrf() }} + {{ u.username }} + + + +
+
+ {{ csrf() }} + +
+
+ {% else %} +

No other users yet.

+ {% endfor %} +

New user

+
+ {{ csrf() }} + + + +
+
+
+ +
+

Mail

+

Used to send login codes. Saved to patchbay.conf.

+
+ {{ csrf() }} + + + + + + +
+
+
+ {{ csrf() }} + +
+
+ +
+

Statistics retention

+
+ {{ csrf() }} + + + +
+
+
+{% endif %} +{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/web/patchbay_web/templates/stats.html b/web/patchbay_web/templates/stats.html new file mode 100644 index 0000000..14ec46f --- /dev/null +++ b/web/patchbay_web/templates/stats.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}Stats{% endblock %} +{% block content %} +

Stats

+{% if not sinks %} +

No connections yet. Patch a source into a sink on the Patch page first.

+{% else %} +
+ +
+ {% for r in ['1h', '24h', '7d', '30d', '1y', 'all'] %} + + {% endfor %} +
+
+ +
+
-
In now (towards source)
+
-
Out now (back to peer)
+
-
Open connections
+
-
Transferred in selected range
+
+ +
+
+

Throughput

+
+ In + Out +
+
+
+
+ +
+

New connections

+
+
+ +
+ Table view +
+ + + +
TimeInOutConnections
+
+
+ +{% endif %} +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/web/patchbay_web/templates/verify.html b/web/patchbay_web/templates/verify.html new file mode 100644 index 0000000..d336216 --- /dev/null +++ b/web/patchbay_web/templates/verify.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Login code{% endblock %} +{% block bodyclass %}auth{% endblock %} +{% block content %} +
+

Check your email

+

Enter the 6-digit code we sent you. It is valid for 10 minutes.

+ + + +

Start over

+
+{% endblock %} diff --git a/web/patchbay_web/tls.py b/web/patchbay_web/tls.py new file mode 100644 index 0000000..905a616 --- /dev/null +++ b/web/patchbay_web/tls.py @@ -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 diff --git a/web/patchbay_web/validate.py b/web/patchbay_web/validate.py new file mode 100644 index 0000000..b36fec1 --- /dev/null +++ b/web/patchbay_web/validate.py @@ -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 diff --git a/web/patchbay_web/views.py b/web/patchbay_web/views.py new file mode 100644 index 0000000..a65bd61 --- /dev/null +++ b/web/patchbay_web/views.py @@ -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//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//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//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.") diff --git a/web/tests/__init__.py b/web/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/tests/helpers.py b/web/tests/helpers.py new file mode 100644 index 0000000..5a98c40 --- /dev/null +++ b/web/tests/helpers.py @@ -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") diff --git a/web/tests/test_auth.py b/web/tests/test_auth.py new file mode 100644 index 0000000..da65ea9 --- /dev/null +++ b/web/tests/test_auth.py @@ -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() diff --git a/web/tests/test_conf.py b/web/tests/test_conf.py new file mode 100644 index 0000000..369a292 --- /dev/null +++ b/web/tests/test_conf.py @@ -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() diff --git a/web/tests/test_graph.py b/web/tests/test_graph.py new file mode 100644 index 0000000..7edc480 --- /dev/null +++ b/web/tests/test_graph.py @@ -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()