Add Transparent Proxy and PROXY v2 support and help page

This commit is contained in:
mueller_minki
2026-10-04 19:21:50 +02:00
parent aebb7206f4
commit b7481c2109
24 changed files with 864 additions and 70 deletions

View File

@@ -18,6 +18,7 @@ Dependencies: a C99 compiler, make, pkg-config, libssh2 (1.11+ recommended for A
1. Target: set the sysop account and mail server in `/etc/patchbay/patchbay.conf`, start `patchbayd`, `patchbay-sshd` and `patchbay-web`, open `https://<target>:8443`. 1. Target: set the sysop account and mail server in `/etc/patchbay/patchbay.conf`, start `patchbayd`, `patchbay-sshd` and `patchbay-web`, open `https://<target>:8443`.
2. Client: set `TargetHost` in the config, run `patchbayd --pubkey` and add the key under Settings -> Clients, start `patchbayd`. 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. 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.
4. Optional, per sink: "PROXY v2" passes the visitor's address to services that speak the PROXY protocol (Apache `RemoteIPProxyProtocol On`, nginx `proxy_protocol`). "Transparent source spoofing" makes the connection to the service come from the visitor's address for any program; the source must be on a client and should use a loopback address (patchbayd adds the needed policy routing, see `TransparentTable`).
## Building and testing ## Building and testing

View File

@@ -1,4 +1,5 @@
#include <errno.h> #include <errno.h>
#include <fcntl.h>
#include <libssh2.h> #include <libssh2.h>
#include <net/if.h> #include <net/if.h>
#include <netdb.h> #include <netdb.h>
@@ -10,6 +11,8 @@
#include <string.h> #include <string.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/uio.h>
#include <sys/wait.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
@@ -31,6 +34,7 @@ struct csink {
char bind[64]; char bind[64];
char iface[IFNAMSIZ]; // tunnel sink: pinned to this interface char iface[IFNAMSIZ]; // tunnel sink: pinned to this interface
unsigned ifindex; unsigned ifindex;
int origin; // sink passes the peer address on: report it in PB1
int fd; // -1 while binding fails; retried by sinks_retry() int fd; // -1 while binding fails; retried by sinks_retry()
int keep; int keep;
char state[96]; // last SINKSTATE sent, to report only changes char state[96]; // last SINKSTATE sent, to report only changes
@@ -50,6 +54,8 @@ struct cc {
struct csink *sink; // K_SINK_UDP: listener to answer through struct csink *sink; // K_SINK_UDP: listener to answer through
struct sockaddr_storage peer; struct sockaddr_storage peer;
socklen_t peerlen; socklen_t peerlen;
unsigned char pp[PROXY_V2_MAX]; // K_SRC_UDP: PROXY header for every datagram
size_t pplen;
long last_act; long last_act;
}; };
@@ -77,6 +83,7 @@ static char token[PB_TOKEN_LEN + 1];
static int hub_port; static int hub_port;
static int hello_done; static int hello_done;
static long last_svc, last_retry; static long last_svc, last_retry;
static int spoof_routing_done; // 1 = rules added by us, -1 = attempted and failed
static long ctl_last_rx; // last control data; the hub PINGs every 30 s static long ctl_last_rx; // last control data; the hub PINGs every 30 s
static volatile sig_atomic_t sig_stop; static volatile sig_atomic_t sig_stop;
@@ -416,8 +423,11 @@ static void udp_local_io(struct cc *c)
int len; int len;
while ((len = udp_frame_peek(&c->from_ch, &payload)) != 0) { while ((len = udp_frame_peek(&c->from_ch, &payload)) != 0) {
size_t plen = len == -2 ? 0 : (size_t)len; size_t plen = len == -2 ? 0 : (size_t)len;
if (c->kind == K_SRC_UDP && c->fd >= 0) if (c->kind == K_SRC_UDP && c->fd >= 0) {
send(c->fd, payload, plen, MSG_DONTWAIT); struct iovec iov[2] = { { c->pp, c->pplen }, { (void *)payload, plen } };
struct msghdr mh = { .msg_iov = iov + !c->pplen, .msg_iovlen = c->pplen ? 2 : 1 };
sendmsg(c->fd, &mh, MSG_DONTWAIT);
}
else if (c->kind == K_SINK_UDP && c->sink && c->sink->fd >= 0) 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); sendto(c->sink->fd, payload, plen, MSG_DONTWAIT, (struct sockaddr *)&c->peer, c->peerlen);
buf_consume(&c->from_ch, plen + 2); buf_consume(&c->from_ch, plen + 2);
@@ -490,7 +500,7 @@ static void sink_bind(struct csink *s)
sink_report(s, "ok -"); sink_report(s, "ok -");
} }
static void sinks_add(int id, int proto, const char *bind, int port, const char *iface) static void sinks_add(int id, int proto, const char *bind, int port, const char *iface, int origin)
{ {
if (!strcmp(iface, "-") || !iface_valid(iface)) if (!strcmp(iface, "-") || !iface_valid(iface))
iface = ""; iface = "";
@@ -498,6 +508,7 @@ static void sinks_add(int id, int proto, const char *bind, int port, const char
if (s->sink_id == id && s->proto == proto && s->port == port && !strcmp(s->bind, bind) && if (s->sink_id == id && s->proto == proto && s->port == port && !strcmp(s->bind, bind) &&
!strcmp(s->iface, iface)) { !strcmp(s->iface, iface)) {
s->keep = 1; s->keep = 1;
s->origin = origin;
// The hub forgets states on reload; repeat ours. // The hub forgets states on reload; repeat ours.
s->state[0] = '\0'; s->state[0] = '\0';
sink_report(s, s->fd >= 0 ? "ok -" : "err bind_failed"); sink_report(s, s->fd >= 0 ? "ok -" : "err bind_failed");
@@ -520,6 +531,7 @@ static void sinks_add(int id, int proto, const char *bind, int port, const char
s->sink_id = id; s->sink_id = id;
s->proto = proto; s->proto = proto;
s->port = port; s->port = port;
s->origin = origin;
s->fd = -1; s->fd = -1;
snprintf(s->bind, sizeof(s->bind), "%s", bind); snprintf(s->bind, sizeof(s->bind), "%s", bind);
snprintf(s->iface, sizeof(s->iface), "%s", iface); snprintf(s->iface, sizeof(s->iface), "%s", iface);
@@ -561,11 +573,30 @@ static void sinks_end(void)
} }
} }
// Data channel header; sinks with an origin add the addresses they saw.
static void sink_header(struct buf *b, struct csink *s, const struct sockaddr_storage *peer, int local_fd)
{
struct sockaddr_storage p = *peer, l;
socklen_t ll = sizeof(l);
if (!s->origin || getsockname(local_fd, (struct sockaddr *)&l, &ll) < 0) {
buf_printf(b, "PB1 %s SINK %d\n", token, s->sink_id);
return;
}
sockaddr_unmap(&p);
sockaddr_unmap(&l);
char ps[64], ls[64];
sockaddr_str((struct sockaddr *)&p, ps, sizeof(ps));
sockaddr_str((struct sockaddr *)&l, ls, sizeof(ls));
buf_printf(b, "PB1 %s SINK %d %s %s\n", token, s->sink_id, ps, ls);
}
static void sink_accept(struct csink *s) static void sink_accept(struct csink *s)
{ {
if (s->proto == PROTO_TCP) { if (s->proto == PROTO_TCP) {
for (;;) { for (;;) {
int fd = accept4(s->fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); struct sockaddr_storage peer;
socklen_t plen = sizeof(peer);
int fd = accept4(s->fd, (struct sockaddr *)&peer, &plen, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (fd < 0) if (fd < 0)
return; return;
tune_stream(fd); tune_stream(fd);
@@ -574,7 +605,7 @@ static void sink_accept(struct csink *s)
close(fd); close(fd);
continue; continue;
} }
buf_printf(&c->to_ch, "PB1 %s SINK %d\n", token, s->sink_id); sink_header(&c->to_ch, s, &peer, fd);
} }
} }
@@ -597,7 +628,8 @@ static void sink_accept(struct csink *s)
c->sink = s; c->sink = s;
c->peer = peer; c->peer = peer;
c->peerlen = plen; c->peerlen = plen;
buf_printf(&c->to_ch, "PB1 %s SINK %d\n", token, s->sink_id); // Local address of the listener; 0.0.0.0 when bound to any.
sink_header(&c->to_ch, s, &peer, s->fd);
} }
if (c->to_ch.len < BUF_LIMIT) if (c->to_ch.len < BUF_LIMIT)
udp_frame_append(&c->to_ch, tmp, (size_t)n); udp_frame_append(&c->to_ch, tmp, (size_t)n);
@@ -605,6 +637,117 @@ static void sink_accept(struct csink *s)
} }
} }
/* Transparent source spoofing */
// Runs ip(8) without a shell. Returns its exit status, -1 if it did not run.
static int run_ip(const char *const argv[])
{
pid_t pid = fork();
if (pid < 0)
return -1;
if (pid == 0) {
int nul = open("/dev/null", O_RDWR);
if (nul >= 0) {
dup2(nul, STDOUT_FILENO);
dup2(nul, STDERR_FILENO);
}
execvp("ip", (char *const *)argv);
_exit(127);
}
int st;
while (waitpid(pid, &st, 0) < 0)
if (errno != EINTR)
return -1;
return WIFEXITED(st) ? WEXITSTATUS(st) : -1;
}
// A loopback service answers a spoofed peer address; without this the reply
// would be routed out of the host instead of back to our transparent socket
// (same setup as go-mmproxy). Only packets from loopback addresses that are
// not addressed to a local address are affected.
static void spoof_routing(int add)
{
char tab[16];
snprintf(tab, sizeof(tab), "%d", cfg->transparent_table);
static const char *const fams[2][3] = { { "-4", "127.0.0.0/8", "0.0.0.0/0" }, { "-6", "::1/128", "::/0" } };
int ok4 = 1;
for (int i = 0; i < 2; i++) {
const char *fam = fams[i][0];
// Remove copies left by an earlier run, then add exactly one.
for (int k = 0; k < 16; k++) {
const char *del[] = { "ip", fam, "rule", "del", "pref", tab, NULL };
if (run_ip(del) != 0)
break;
}
const char *flush[] = { "ip", fam, "route", "flush", "table", tab, NULL };
run_ip(flush);
if (!add)
continue;
const char *rule[] = { "ip", fam, "rule", "add", "pref", tab, "from", fams[i][1], "iif", "lo", "lookup", tab, NULL };
const char *route[] = { "ip", fam, "route", "replace", "local", fams[i][2], "dev", "lo", "table", tab, NULL };
int ok = run_ip(rule) == 0 && run_ip(route) == 0;
if (i == 0)
ok4 = ok;
else if (!ok)
log_debug("transparent spoofing: IPv6 policy routing not set up");
}
if (!add)
return;
if (ok4)
log_info("transparent spoofing: policy routing set up (table %s)", tab);
else
log_warn("transparent spoofing: setting up policy routing failed (is iproute2 installed?)");
spoof_routing_done = ok4 ? 1 : -1;
}
/* Source connections */
// OPEN <conn_id> <proto> <host> <port> <iface|-> [<origin> <peer> <local>]
static void open_source(char **f, int n)
{
int proto = proto_parse(f[2]);
if (proto < 0)
return;
const char *iface = n > 5 && strcmp(f[5], "-") && iface_valid(f[5]) ? f[5] : NULL;
struct sockaddr_storage peer, local;
int proxy = 0, spoof = 0;
if (n >= 9 && sockaddr_parse(f[7], &peer) == 0 && sockaddr_parse(f[8], &local) == 0) {
proxy = !strcmp(f[6], "proxy");
spoof = !strcmp(f[6], "spoof");
}
if (spoof && !spoof_routing_done && cfg->transparent_table > 0)
spoof_routing(1);
int inprog = 0;
int fd = net_connect(f[3], atoi(f[4]), proto, iface, spoof ? (struct sockaddr *)&peer : NULL, &inprog);
if (fd < 0 && spoof) {
// E.g. an IPv6 peer for an IPv4-only service, or no CAP_NET_ADMIN.
log_warn("OPEN %s: cannot connect to %s:%s from %s (%s), connecting without spoofing", f[1], f[3], f[4],
f[7], strerror(errno));
fd = net_connect(f[3], atoi(f[4]), proto, iface, NULL, &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;
if (proxy) {
unsigned char hdr[PROXY_V2_MAX];
size_t len = proxy_v2_header(hdr, proto, (struct sockaddr *)&peer, (struct sockaddr *)&local);
// TCP: first bytes towards the service. UDP: in front of every datagram.
if (proto == PROTO_TCP)
buf_append(&c->from_ch, hdr, len);
else
memcpy(c->pp, hdr, c->pplen = len);
}
buf_printf(&c->to_ch, "PB1 %s OPEN %s\n", token, f[1]);
}
/* Control messages */ /* Control messages */
static void send_services(void) static void send_services(void)
@@ -659,25 +802,11 @@ static void ctl_line(char *line)
} else if (!strcmp(f[0], "SINK") && n >= 5) { } else if (!strcmp(f[0], "SINK") && n >= 5) {
int proto = proto_parse(f[2]); int proto = proto_parse(f[2]);
if (proto >= 0) if (proto >= 0)
sinks_add(atoi(f[1]), proto, f[3], atoi(f[4]), n > 5 ? f[5] : "-"); sinks_add(atoi(f[1]), proto, f[3], atoi(f[4]), n > 5 ? f[5] : "-", n > 6 && strcmp(f[6], "-"));
} else if (!strcmp(f[0], "SINKS-END")) { } else if (!strcmp(f[0], "SINKS-END")) {
sinks_end(); sinks_end();
} else if (!strcmp(f[0], "OPEN") && n >= 5) { } else if (!strcmp(f[0], "OPEN") && n >= 5) {
int proto = proto_parse(f[2]); open_source(f, n);
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")) { } else if (!strcmp(f[0], "SVC-REQ")) {
send_services(); send_services();
} else if (!strcmp(f[0], "PING")) { } else if (!strcmp(f[0], "PING")) {
@@ -1099,6 +1228,8 @@ int client_run(const struct pb_config *conf)
usleep(100000); usleep(100000);
backoff = backoff < 30 ? backoff * 2 : 30; backoff = backoff < 30 ? backoff * 2 : 30;
} }
if (spoof_routing_done > 0)
spoof_routing(0);
libssh2_exit(); libssh2_exit();
return 0; return 0;
} }

View File

@@ -18,6 +18,7 @@ void config_defaults(struct pb_config *c)
snprintf(c->known_hosts, sizeof(c->known_hosts), "/etc/patchbay/known_hosts"); snprintf(c->known_hosts, sizeof(c->known_hosts), "/etc/patchbay/known_hosts");
snprintf(c->ssh_user, sizeof(c->ssh_user), "patchbay"); snprintf(c->ssh_user, sizeof(c->ssh_user), "patchbay");
c->services_interval = 30; c->services_interval = 30;
c->transparent_table = 470;
c->ssh_port = 0; // 0 = use target_port c->ssh_port = 0; // 0 = use target_port
c->hub_port = 7701; c->hub_port = 7701;
snprintf(c->sshd_host_key, sizeof(c->sshd_host_key), "/etc/patchbay/ssh_host_ed25519_key"); snprintf(c->sshd_host_key, sizeof(c->sshd_host_key), "/etc/patchbay/ssh_host_ed25519_key");
@@ -93,6 +94,8 @@ int config_parse_line(struct pb_config *c, char *line)
STR(ssh_user); STR(ssh_user);
} else if (!strcasecmp(key, "ServicesInterval")) { } else if (!strcasecmp(key, "ServicesInterval")) {
c->services_interval = atoi(val); c->services_interval = atoi(val);
} else if (!strcasecmp(key, "TransparentTable")) {
c->transparent_table = atoi(val);
} else if (!strcasecmp(key, "HubPort")) { } else if (!strcasecmp(key, "HubPort")) {
c->hub_port = atoi(val); c->hub_port = atoi(val);
} else if (!strcasecmp(key, "SSHHostKey")) { } else if (!strcasecmp(key, "SSHHostKey")) {

View File

@@ -21,6 +21,7 @@ struct pb_config {
char known_hosts[256]; char known_hosts[256];
char ssh_user[64]; char ssh_user[64];
int services_interval; // seconds between Services reports int services_interval; // seconds between Services reports
int transparent_table; // policy routing table for spoofing, 0 = set up by hand
// target role // target role
int ssh_port; // dedicated sshd port (same value clients use as TargetPort) int ssh_port; // dedicated sshd port (same value clients use as TargetPort)

View File

@@ -32,8 +32,9 @@ sqlite3 *db_open(const char *path)
sqlite3_close(db); sqlite3_close(db);
return NULL; return NULL;
} }
// Migration for databases from before tunnel nodes; fails harmlessly if present. // Migrations for databases from before tunnel nodes / origin; fail harmlessly if present.
sqlite3_exec(db, "ALTER TABLE nodes ADD COLUMN iface TEXT NOT NULL DEFAULT ''", NULL, NULL, NULL); sqlite3_exec(db, "ALTER TABLE nodes ADD COLUMN iface TEXT NOT NULL DEFAULT ''", NULL, NULL, NULL);
sqlite3_exec(db, "ALTER TABLE nodes ADD COLUMN origin TEXT NOT NULL DEFAULT ''", NULL, NULL, NULL);
return db; return db;
} }

View File

@@ -10,6 +10,7 @@
#include <sys/epoll.h> #include <sys/epoll.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/uio.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
@@ -40,11 +41,15 @@ enum node_type {
NODE_TUNNEL_SOURCE, NODE_TUNNEL_SINK, NODE_TUNNEL_SOURCE, NODE_TUNNEL_SINK,
}; };
// How the connecting peer's address reaches the service (nodes.origin).
enum { ORIGIN_NONE, ORIGIN_PROXY, ORIGIN_SPOOF };
// A sink resolved back to its source. Host id 0 means the target itself. // A sink resolved back to its source. Host id 0 means the target itself.
struct route { struct route {
int sink_id; int sink_id;
int sink_type; int sink_type;
int sink_client; int sink_client;
int origin;
char bind[256]; char bind[256];
char sink_iface[IFNAMSIZ]; char sink_iface[IFNAMSIZ];
int port; int port;
@@ -138,6 +143,11 @@ struct conn {
struct sockaddr_storage peer; struct sockaddr_storage peer;
socklen_t peerlen; socklen_t peerlen;
struct buf ain, aout, bin, bout; struct buf ain, aout, bin, bout;
int origin;
int has_addr; // o_peer/o_local known (sinks with an origin)
struct sockaddr_storage o_peer, o_local;
unsigned char pp[PROXY_V2_MAX]; // PROXY header for a source on the target
size_t pplen;
int dead; int dead;
}; };
@@ -178,6 +188,20 @@ static const char *iface_field(const char *iface)
return iface[0] ? iface : "-"; return iface[0] ? iface : "-";
} }
static const char *origin_name(int origin)
{
return origin == ORIGIN_PROXY ? "proxy" : origin == ORIGIN_SPOOF ? "spoof" : "-";
}
static int origin_parse(const char *s)
{
if (!strcmp(s, "proxy_v2"))
return ORIGIN_PROXY;
if (!strcmp(s, "transparent"))
return ORIGIN_SPOOF;
return ORIGIN_NONE;
}
/* Epoll helpers */ /* Epoll helpers */
static void ev_set(struct ev *e, uint32_t mask) static void ev_set(struct ev *e, uint32_t mask)
@@ -291,8 +315,8 @@ static void ctl_push_sinks(struct ctl *c)
// Invalid routes are not pushed so the client does not bind a dead port. // 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]) if (!route_on_client(r, c->client_id) || r->err[0])
continue; continue;
ctl_send(c, "SINK %d %s %s %d %s", r->sink_id, proto_name(r->proto), r->bind, r->port, ctl_send(c, "SINK %d %s %s %d %s %s", r->sink_id, proto_name(r->proto), r->bind, r->port,
iface_field(r->sink_iface)); iface_field(r->sink_iface), origin_name(r->origin));
} }
ctl_send(c, "SINKS-END"); ctl_send(c, "SINKS-END");
} }
@@ -529,6 +553,7 @@ static struct conn *conn_new(struct route *r, int a_kind)
c->sink_id = r->sink_id; c->sink_id = r->sink_id;
c->src_client = r->src_client; c->src_client = r->src_client;
c->proto = r->proto; c->proto = r->proto;
c->origin = r->origin;
c->state = CONN_WAIT; c->state = CONN_WAIT;
c->a_kind = a_kind; c->a_kind = a_kind;
c->b_kind = B_STREAM; c->b_kind = B_STREAM;
@@ -602,8 +627,12 @@ static void dg_to_b(struct conn *c, const char *p, size_t n)
if (c->st) if (c->st)
stats_add(c->st, n, 0); stats_add(c->st, n, 0);
if (c->b_kind == B_DGRAM) { if (c->b_kind == B_DGRAM) {
if (c->state == CONN_RELAY) if (c->state == CONN_RELAY) {
send(c->b.fd, p, n, MSG_DONTWAIT); // A PROXY header goes in front of every datagram.
struct iovec iov[2] = { { c->pp, c->pplen }, { (void *)p, n } };
struct msghdr mh = { .msg_iov = iov + !c->pplen, .msg_iovlen = c->pplen ? 2 : 1 };
sendmsg(c->b.fd, &mh, MSG_DONTWAIT);
}
return; return;
} }
// Like a real UDP path, drop when the tunnel cannot keep up. // Like a real UDP path, drop when the tunnel cannot keep up.
@@ -760,6 +789,11 @@ static void conn_start(struct conn *c)
conn_kill(c); conn_kill(c);
return; return;
} }
// Fresh socket with an empty send buffer: the header goes out in one write.
if (c->pplen && write(c->b.fd, c->pp, c->pplen) != (ssize_t)c->pplen) {
conn_kill(c);
return;
}
conn_relay(c); conn_relay(c);
} }
@@ -781,18 +815,30 @@ static void conn_attach_channel(struct conn *c, int fd)
// the source is a tunnel source of the target. // the source is a tunnel source of the target.
static int conn_request_source(struct conn *c, struct route *r) static int conn_request_source(struct conn *c, struct route *r)
{ {
int origin = c->has_addr ? c->origin : ORIGIN_NONE;
if (r->src_client) { if (r->src_client) {
struct ctl *src = ctl_by_client(r->src_client); struct ctl *src = ctl_by_client(r->src_client);
if (!src) if (!src)
return -1; return -1;
conn_set_mode(c); 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, if (origin == ORIGIN_NONE) {
iface_field(r->src_iface)); 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));
} else {
char peer[64], local[64];
sockaddr_str((struct sockaddr *)&c->o_peer, peer, sizeof(peer));
sockaddr_str((struct sockaddr *)&c->o_local, local, sizeof(local));
ctl_send(src, "OPEN %d %s %s %d %s %s %s %s", c->id, proto_name(r->proto), r->src_host, r->src_port,
iface_field(r->src_iface), origin_name(origin), peer, local);
}
return 0; return 0;
} }
// Spoofing is client-only (route error), so only PROXY applies here.
if (origin == ORIGIN_PROXY)
c->pplen = proxy_v2_header(c->pp, c->proto, (struct sockaddr *)&c->o_peer, (struct sockaddr *)&c->o_local);
int inprog = 0; int inprog = 0;
int fd = net_connect(r->src_host, r->src_port, r->proto, r->src_iface, &inprog); int fd = net_connect(r->src_host, r->src_port, r->proto, r->src_iface, NULL, &inprog);
if (fd < 0) { if (fd < 0) {
log_debug("sink %d: connect %s:%d failed: %s", r->sink_id, r->src_host, r->src_port, strerror(errno)); log_debug("sink %d: connect %s:%d failed: %s", r->sink_id, r->src_host, r->src_port, strerror(errno));
return -1; return -1;
@@ -824,10 +870,26 @@ static void conn_connected(struct conn *c)
/* Target sinks */ /* Target sinks */
// Records the addresses a sink saw for its origin mode.
static void conn_set_addr(struct conn *c, const struct sockaddr_storage *peer, int local_fd)
{
if (c->origin == ORIGIN_NONE)
return;
socklen_t ll = sizeof(c->o_local);
if (getsockname(local_fd, (struct sockaddr *)&c->o_local, &ll) < 0)
return;
c->o_peer = *peer;
sockaddr_unmap(&c->o_peer);
sockaddr_unmap(&c->o_local);
c->has_addr = 1;
}
static void tsink_accept_tcp(struct tsink *ts) static void tsink_accept_tcp(struct tsink *ts)
{ {
for (;;) { for (;;) {
int fd = accept4(ts->ev.fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC); struct sockaddr_storage peer;
socklen_t plen = sizeof(peer);
int fd = accept4(ts->ev.fd, (struct sockaddr *)&peer, &plen, SOCK_NONBLOCK | SOCK_CLOEXEC);
if (fd < 0) if (fd < 0)
return; return;
struct route *r = route_find(ts->sink_id); struct route *r = route_find(ts->sink_id);
@@ -842,6 +904,7 @@ static void tsink_accept_tcp(struct tsink *ts)
continue; continue;
} }
ev_init(&c->a, EV_CONN_A, fd, c); ev_init(&c->a, EV_CONN_A, fd, c);
conn_set_addr(c, &peer, fd);
if (conn_request_source(c, r) < 0) { if (conn_request_source(c, r) < 0) {
log_debug("sink %d: source unavailable", ts->sink_id); log_debug("sink %d: source unavailable", ts->sink_id);
conn_kill(c); conn_kill(c);
@@ -875,6 +938,8 @@ static void tsink_recv_udp(struct tsink *ts)
c->ts = ts; c->ts = ts;
c->peer = peer; c->peer = peer;
c->peerlen = plen; c->peerlen = plen;
// Local address of the listener; 0.0.0.0 when bound to any.
conn_set_addr(c, &peer, ts->ev.fd);
if (conn_request_source(c, r) < 0) { if (conn_request_source(c, r) < 0) {
conn_kill(c); conn_kill(c);
continue; continue;
@@ -890,7 +955,7 @@ static void tsink_recv_udp(struct tsink *ts)
static void pend_read(struct pend *p) static void pend_read(struct pend *p)
{ {
char hdr[160]; char hdr[256];
ssize_t n = recv(p->ev.fd, hdr, sizeof(hdr) - 1, MSG_PEEK); ssize_t n = recv(p->ev.fd, hdr, sizeof(hdr) - 1, MSG_PEEK);
if (n == 0 || (n < 0 && errno != EAGAIN)) { if (n == 0 || (n < 0 && errno != EAGAIN)) {
p->dead = 1; p->dead = 1;
@@ -915,7 +980,7 @@ static void pend_read(struct pend *p)
char *f[PB_MAX_FIELDS]; char *f[PB_MAX_FIELDS];
int nf = line_split(hdr, 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; struct ctl *owner = (nf == 4 || nf == 6) && !strcmp(f[0], "PB1") ? ctl_by_token(f[1]) : NULL;
p->dead = 1; // the fd is handed over or closed below p->dead = 1; // the fd is handed over or closed below
if (!owner) { if (!owner) {
log_warn("data channel with invalid header rejected"); log_warn("data channel with invalid header rejected");
@@ -949,6 +1014,10 @@ static void pend_read(struct pend *p)
return; return;
} }
ev_init(&c->a, EV_CONN_A, fd, c); ev_init(&c->a, EV_CONN_A, fd, c);
// Addresses the client sink saw; without them the origin is dropped.
if (nf == 6 && c->origin != ORIGIN_NONE && sockaddr_parse(f[4], &c->o_peer) == 0 &&
sockaddr_parse(f[5], &c->o_local) == 0)
c->has_addr = 1;
if (conn_request_source(c, r) < 0) { if (conn_request_source(c, r) < 0) {
conn_kill(c); conn_kill(c);
return; return;
@@ -962,7 +1031,7 @@ static void pend_read(struct pend *p)
/* Patch graph */ /* Patch graph */
struct gnode { struct gnode {
int id, type, client_id, port, proto, input; int id, type, client_id, port, proto, input, origin;
char host[256]; char host[256];
char iface[IFNAMSIZ]; char iface[IFNAMSIZ];
}; };
@@ -1016,6 +1085,8 @@ static void route_resolve(struct route *r, struct gnode *sink, struct gnode *g,
snprintf(r->err, sizeof(r->err), "tunnel source has no interface"); snprintf(r->err, sizeof(r->err), "tunnel source has no interface");
else if (tunnel && host_empty(cur->host)) else if (tunnel && host_empty(cur->host))
snprintf(r->err, sizeof(r->err), "tunnel source has no peer address"); snprintf(r->err, sizeof(r->err), "tunnel source has no peer address");
else if (r->origin == ORIGIN_SPOOF && !cur->client_id)
snprintf(r->err, sizeof(r->err), "transparent spoofing needs a source on a client");
else { else {
r->src_client = cur->client_id; 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_host, sizeof(r->src_host), "%s", host_empty(cur->host) ? "127.0.0.1" : cur->host);
@@ -1038,7 +1109,7 @@ static int load_graph(void)
int n = 0, cap = 0; int n = 0, cap = 0;
sqlite3_stmt *st; sqlite3_stmt *st;
const char *sql = "SELECT n.id, n.type, IFNULL(n.client_id, 0), n.host, n.port, n.proto, n.iface, " 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"; "IFNULL((SELECT from_node FROM links WHERE to_node = n.id), 0), n.origin FROM nodes n";
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
log_err("graph: %s", sqlite3_errmsg(db)); log_err("graph: %s", sqlite3_errmsg(db));
return -1; return -1;
@@ -1058,6 +1129,7 @@ static int load_graph(void)
if (x->iface[0] && !iface_valid(x->iface)) if (x->iface[0] && !iface_valid(x->iface))
x->iface[0] = '\0'; x->iface[0] = '\0';
x->input = sqlite3_column_int(st, 7); x->input = sqlite3_column_int(st, 7);
x->origin = origin_parse((const char *)sqlite3_column_text(st, 8));
} }
sqlite3_finalize(st); sqlite3_finalize(st);
@@ -1073,6 +1145,7 @@ static int load_graph(void)
r->sink_client = t == NODE_PUBLIC_SINK ? 0 : g[i].client_id; r->sink_client = t == NODE_PUBLIC_SINK ? 0 : g[i].client_id;
r->proto = g[i].proto; r->proto = g[i].proto;
r->port = g[i].port; r->port = g[i].port;
r->origin = g[i].origin;
if (t == NODE_TUNNEL_SINK) { if (t == NODE_TUNNEL_SINK) {
// Bound to the interface, so any local address of it is accepted. // Bound to the interface, so any local address of it is accepted.
snprintf(r->bind, sizeof(r->bind), "0.0.0.0"); snprintf(r->bind, sizeof(r->bind), "0.0.0.0");

View File

@@ -5,7 +5,9 @@
#include <netinet/in.h> #include <netinet/in.h>
#include <net/if.h> #include <net/if.h>
#include <netinet/tcp.h> #include <netinet/tcp.h>
#include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include <strings.h> #include <strings.h>
#include <sys/stat.h> #include <sys/stat.h>
@@ -14,6 +16,13 @@
#include "net.h" #include "net.h"
#ifndef IP_TRANSPARENT
#define IP_TRANSPARENT 19
#endif
#ifndef IPV6_TRANSPARENT
#define IPV6_TRANSPARENT 75
#endif
int proto_parse(const char *s) int proto_parse(const char *s)
{ {
if (!strcasecmp(s, "tcp")) if (!strcasecmp(s, "tcp"))
@@ -109,21 +118,52 @@ int net_listen(const char *addr, int port, int proto, const char *iface)
return fd; return fd;
} }
int net_connect(const char *host, int port, int proto, const char *iface, int *in_progress) static socklen_t sa_len(const struct sockaddr *sa)
{
return sa->sa_family == AF_INET6 ? sizeof(struct sockaddr_in6) : sizeof(struct sockaddr_in);
}
// Binds fd to a foreign address. The peer's own port is preferred so the
// service logs it; it is taken when the same peer arrives through two sinks.
static int bind_transparent(int fd, const struct sockaddr *src)
{
int one = 1;
int v6 = src->sa_family == AF_INET6;
if (setsockopt(fd, v6 ? IPPROTO_IPV6 : IPPROTO_IP, v6 ? IPV6_TRANSPARENT : IP_TRANSPARENT, &one,
sizeof(one)) < 0)
return -1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
if (bind(fd, src, sa_len(src)) == 0)
return 0;
if (errno != EADDRINUSE)
return -1;
struct sockaddr_storage any;
memcpy(&any, src, sa_len(src));
if (v6)
((struct sockaddr_in6 *)&any)->sin6_port = 0;
else
((struct sockaddr_in *)&any)->sin_port = 0;
return bind(fd, (struct sockaddr *)&any, sa_len(src));
}
int net_connect(const char *host, int port, int proto, const char *iface, const struct sockaddr *spoof,
int *in_progress)
{ {
struct addrinfo *res; struct addrinfo *res;
*in_progress = 0; *in_progress = 0;
if (resolve(host, port, proto, 0, &res) < 0) if (resolve(host, port, proto, 0, &res) < 0)
return -1; return -1;
int fd = -1, err = 0; int fd = -1, err = spoof ? EAFNOSUPPORT : 0;
for (struct addrinfo *ai = res; ai; ai = ai->ai_next) { for (struct addrinfo *ai = res; ai; ai = ai->ai_next) {
if (spoof && ai->ai_family != spoof->sa_family)
continue;
fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); fd = socket(ai->ai_family, ai->ai_socktype | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (fd < 0) { if (fd < 0) {
err = errno; err = errno;
continue; continue;
} }
if (bind_device(fd, iface) < 0) { if (bind_device(fd, iface) < 0 || (spoof && bind_transparent(fd, spoof) < 0)) {
err = errno; err = errno;
close(fd); close(fd);
fd = -1; fd = -1;
@@ -214,3 +254,106 @@ void sockaddr_str(const struct sockaddr *sa, char *out, size_t outsz)
snprintf(out, outsz, "?"); snprintf(out, outsz, "?");
} }
} }
int sockaddr_parse(const char *s, struct sockaddr_storage *ss)
{
char host[INET6_ADDRSTRLEN];
const char *colon;
size_t hl;
memset(ss, 0, sizeof(*ss));
if (*s == '[') {
const char *end = strchr(s, ']');
if (!end || end[1] != ':')
return -1;
s++;
hl = (size_t)(end - s);
colon = end + 1;
} else {
colon = strrchr(s, ':');
if (!colon)
return -1;
hl = (size_t)(colon - s);
}
if (hl == 0 || hl >= sizeof(host))
return -1;
memcpy(host, s, hl);
host[hl] = '\0';
char *e;
long port = strtol(colon + 1, &e, 10);
if (*e || e == colon + 1 || port < 0 || port > 65535)
return -1;
struct sockaddr_in *s4 = (struct sockaddr_in *)ss;
struct sockaddr_in6 *s6 = (struct sockaddr_in6 *)ss;
if (inet_pton(AF_INET, host, &s4->sin_addr) == 1) {
s4->sin_family = AF_INET;
s4->sin_port = htons((uint16_t)port);
} else if (inet_pton(AF_INET6, host, &s6->sin6_addr) == 1) {
s6->sin6_family = AF_INET6;
s6->sin6_port = htons((uint16_t)port);
} else {
return -1;
}
return 0;
}
void sockaddr_unmap(struct sockaddr_storage *ss)
{
struct sockaddr_in6 *s6 = (struct sockaddr_in6 *)ss;
if (ss->ss_family != AF_INET6 || !IN6_IS_ADDR_V4MAPPED(&s6->sin6_addr))
return;
struct sockaddr_in s4;
memset(&s4, 0, sizeof(s4));
s4.sin_family = AF_INET;
s4.sin_port = s6->sin6_port;
memcpy(&s4.sin_addr, &s6->sin6_addr.s6_addr[12], 4);
memset(ss, 0, sizeof(*ss));
memcpy(ss, &s4, sizeof(s4));
}
/* PROXY protocol v2 */
static void put_v6(unsigned char *out, const struct sockaddr *sa)
{
if (sa->sa_family == AF_INET6) {
memcpy(out, &((const struct sockaddr_in6 *)sa)->sin6_addr, 16);
return;
}
memset(out, 0, 10);
out[10] = out[11] = 0xff;
memcpy(out + 12, &((const struct sockaddr_in *)sa)->sin_addr, 4);
}
static uint16_t sa_port(const struct sockaddr *sa)
{
// Already network byte order.
return sa->sa_family == AF_INET6 ? ((const struct sockaddr_in6 *)sa)->sin6_port
: ((const struct sockaddr_in *)sa)->sin_port;
}
size_t proxy_v2_header(unsigned char *out, int proto, const struct sockaddr *src, const struct sockaddr *dst)
{
static const unsigned char sig[12] = { 0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a };
int v4 = src->sa_family == AF_INET && dst->sa_family == AF_INET;
size_t alen = v4 ? 12 : 36;
unsigned char *a = out + 16;
uint16_t sp = sa_port(src), dp = sa_port(dst);
memcpy(out, sig, sizeof(sig));
out[12] = 0x21; // version 2, PROXY
out[13] = (unsigned char)((v4 ? 0x10 : 0x20) | (proto == PROTO_UDP ? 0x02 : 0x01));
out[14] = 0;
out[15] = (unsigned char)alen;
if (v4) {
memcpy(a, &((const struct sockaddr_in *)src)->sin_addr, 4);
memcpy(a + 4, &((const struct sockaddr_in *)dst)->sin_addr, 4);
a += 8;
} else {
put_v6(a, src);
put_v6(a + 16, dst);
a += 32;
}
memcpy(a, &sp, 2);
memcpy(a + 2, &dp, 2);
return 16 + alen;
}

View File

@@ -1,6 +1,7 @@
#ifndef PB_NET_H #ifndef PB_NET_H
#define PB_NET_H #define PB_NET_H
#include <stddef.h>
#include <sys/socket.h> #include <sys/socket.h>
enum pb_proto { PROTO_TCP, PROTO_UDP }; enum pb_proto { PROTO_TCP, PROTO_UDP };
@@ -15,8 +16,11 @@ void tune_stream(int fd);
// socket to one interface with SO_BINDTODEVICE. Returns fd or -1 with errno. // 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); 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; // Starts a non-blocking connect, optionally pinned to iface. Returns fd or -1;
// *in_progress set if pending. // *in_progress set if pending. spoof (may be NULL) is a foreign source address
int net_connect(const char *host, int port, int proto, const char *iface, int *in_progress); // bound with IP_TRANSPARENT (needs CAP_NET_ADMIN); only service addresses of
// the same family are tried, EAFNOSUPPORT if there are none.
int net_connect(const char *host, int port, int proto, const char *iface, const struct sockaddr *spoof,
int *in_progress);
int net_listen_unix(const char *path, int mode); int net_listen_unix(const char *path, int mode);
int net_connect_unix(const char *path); int net_connect_unix(const char *path);
@@ -25,5 +29,17 @@ int iface_valid(const char *iface);
// Formats a sockaddr as "addr:port" (IPv6 in brackets). // Formats a sockaddr as "addr:port" (IPv6 in brackets).
void sockaddr_str(const struct sockaddr *sa, char *out, size_t outsz); void sockaddr_str(const struct sockaddr *sa, char *out, size_t outsz);
// Parses the sockaddr_str format back. Returns 0 or -1.
int sockaddr_parse(const char *s, struct sockaddr_storage *ss);
// Turns a v4-mapped IPv6 address into plain IPv4 in place.
void sockaddr_unmap(struct sockaddr_storage *ss);
/* PROXY protocol v2 */
#define PROXY_V2_MAX 52 // 16 byte header + IPv6 address block
// Builds a PROXY v2 header (command PROXY) for a connection from src to dst.
// Mixed families are sent as IPv6 with a v4-mapped address. Returns length.
size_t proxy_v2_header(unsigned char *out, int proto, const struct sockaddr *src, const struct sockaddr *dst);
#endif #endif

View File

@@ -11,8 +11,8 @@
* (patchbayd --relay) bridges it to the hub socket on the target. * (patchbayd --relay) bridges it to the hub socket on the target.
* *
* hub -> client: HELLO <token> <hub_port> * hub -> client: HELLO <token> <hub_port>
* SINKS-BEGIN / SINK <sink_id> <proto> <bind> <port> <iface|-> / SINKS-END * SINKS-BEGIN / SINK <sink_id> <proto> <bind> <port> <iface|-> [<origin>] / SINKS-END
* OPEN <conn_id> <proto> <host> <port> <iface|-> * OPEN <conn_id> <proto> <host> <port> <iface|-> [<origin> <peer> <local>]
* SVC-REQ, PING * SVC-REQ, PING
* client -> hub: HELLO <version> <hostname> * client -> hub: HELLO <version> <hostname>
* SVC-BEGIN / SVC <proto> <addr> <port> <pid> <process> / SVC-END * SVC-BEGIN / SVC <proto> <addr> <port> <pid> <process> / SVC-END
@@ -22,16 +22,22 @@
* *
* <iface> pins a socket to an interface (tunnel nodes, SO_BINDTODEVICE). * <iface> pins a socket to an interface (tunnel nodes, SO_BINDTODEVICE).
* *
* <origin> says how the connecting peer's address reaches the service: "proxy"
* (PROXY v2 header in front of the stream / every UDP datagram) or "spoof"
* (connect from the peer's address with IP_TRANSPARENT). <peer> and <local>
* are the addresses the sink saw, "addr:port" with IPv6 in brackets.
*
* Data channels are direct-tcpip channels to 127.0.0.1:HubPort on the target. * Data channels are direct-tcpip channels to 127.0.0.1:HubPort on the target.
* They start with "PB1 <token> SINK <sink_id>\n" (a client sink accepted a * They start with "PB1 <token> SINK <sink_id> [<peer> <local>]\n" (a client
* connection) or "PB1 <token> OPEN <conn_id>\n" (answer to OPEN), followed by * sink accepted a connection; the addresses only for sinks with an origin) or
* raw stream bytes. UDP flows use length-prefixed frames, see udp_frame_*. * "PB1 <token> OPEN <conn_id>\n" (answer to OPEN), followed by raw stream
* bytes. UDP flows use length-prefixed frames, see udp_frame_*.
*/ */
#define PB_VERSION "0.1.0" #define PB_VERSION "0.1.0"
#define PB_TOKEN_LEN 32 // hex chars #define PB_TOKEN_LEN 32 // hex chars
#define PB_MAX_LINE 1024 #define PB_MAX_LINE 1024
#define PB_MAX_FIELDS 8 #define PB_MAX_FIELDS 12
#define PB_UDP_MAX 65507 #define PB_UDP_MAX 65507
/* Byte buffer */ /* Byte buffer */

View File

@@ -1,5 +1,6 @@
/* Minimal unit test runner: ./test_runner [test_name ...] */ /* Minimal unit test runner: ./test_runner [test_name ...] */
#include <netinet/in.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
@@ -112,6 +113,49 @@ static void test_token(void)
CHECK(random_token(b, sizeof(b)) == 0 && strcmp(a, b)); CHECK(random_token(b, sizeof(b)) == 0 && strcmp(a, b));
} }
static void test_sockaddr(void)
{
struct sockaddr_storage ss;
char out[64];
CHECK(sockaddr_parse("1.2.3.4:5678", &ss) == 0 && ss.ss_family == AF_INET);
sockaddr_str((struct sockaddr *)&ss, out, sizeof(out));
CHECK(!strcmp(out, "1.2.3.4:5678"));
CHECK(sockaddr_parse("[2001:db8::1]:443", &ss) == 0 && ss.ss_family == AF_INET6);
sockaddr_str((struct sockaddr *)&ss, out, sizeof(out));
CHECK(!strcmp(out, "[2001:db8::1]:443"));
CHECK(sockaddr_parse("[::ffff:10.0.0.1]:80", &ss) == 0);
sockaddr_unmap(&ss);
sockaddr_str((struct sockaddr *)&ss, out, sizeof(out));
CHECK(ss.ss_family == AF_INET && !strcmp(out, "10.0.0.1:80"));
CHECK(sockaddr_parse("1.2.3.4", &ss) == -1);
CHECK(sockaddr_parse("1.2.3.4:99999", &ss) == -1);
CHECK(sockaddr_parse("host:80", &ss) == -1);
CHECK(sockaddr_parse("[::1:80", &ss) == -1);
}
static void test_proxy_v2(void)
{
static const unsigned char want4[] = {
0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a,
0x21, 0x11, 0x00, 0x0c,
1, 2, 3, 4, 10, 0, 0, 5, 0x16, 0x2e, 0x01, 0xbb,
};
struct sockaddr_storage src, dst;
unsigned char out[PROXY_V2_MAX];
sockaddr_parse("1.2.3.4:5678", &src);
sockaddr_parse("10.0.0.5:443", &dst);
size_t n = proxy_v2_header(out, PROTO_TCP, (struct sockaddr *)&src, (struct sockaddr *)&dst);
CHECK(n == sizeof(want4) && !memcmp(out, want4, n));
// Mixed families become IPv6 with a v4-mapped address; UDP sets DGRAM.
sockaddr_parse("[2001:db8::1]:1000", &dst);
n = proxy_v2_header(out, PROTO_UDP, (struct sockaddr *)&src, (struct sockaddr *)&dst);
static const unsigned char mapped[16] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 1, 2, 3, 4 };
CHECK(n == 52 && out[13] == 0x22 && out[15] == 36);
CHECK(!memcmp(out + 16, mapped, 16) && out[32] == 0x20 && out[33] == 0x01);
CHECK(out[48] == 0x16 && out[49] == 0x2e && out[50] == 0x03 && out[51] == 0xe8);
}
/* Runner */ /* Runner */
static const struct { const char *name; void (*fn)(void); } tests[] = { static const struct { const char *name; void (*fn)(void); } tests[] = {
@@ -121,6 +165,8 @@ static const struct { const char *name; void (*fn)(void); } tests[] = {
{ "services_parse", test_services_parse }, { "services_parse", test_services_parse },
{ "json", test_json }, { "json", test_json },
{ "token", test_token }, { "token", test_token },
{ "sockaddr", test_sockaddr },
{ "proxy_v2", test_proxy_v2 },
}; };
int main(int argc, char **argv) int main(int argc, char **argv)

View File

@@ -144,6 +144,60 @@ 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=$(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" [ "$r" = 2 ] && ok "tun interfaces reported" || bad "interfaces: $r rows"
# Origin address: PROXY v2 headers (services print the first 28 bytes as hex,
# an IPv4 header is exactly 28) and transparent source spoofing (services
# print the peer address they see).
HEXDUMP="head -c 28 | od -An -tx1 | tr -dc 0-9a-f" # no quotes or colons: socat parses these
on client1 "(socat TCP-LISTEN:9400,fork,reuseaddr SYSTEM:'$HEXDUMP' >/dev/null 2>&1 &) &&
(socat UDP-RECVFROM:9401,bind=127.0.0.1,fork SYSTEM:'$HEXDUMP' >/dev/null 2>&1 &) &&
(socat TCP-LISTEN:9410,fork,reuseaddr SYSTEM:'echo \$SOCAT_PEERADDR' >/dev/null 2>&1 &) &&
(socat UDP-RECVFROM:9411,bind=127.0.0.1,fork SYSTEM:'echo \$SOCAT_PEERADDR' >/dev/null 2>&1 &)"
on target "(ip netns exec peer socat TCP-LISTEN:9402,fork,reuseaddr SYSTEM:'$HEXDUMP' >/dev/null 2>&1 &)"
on target "sqlite3 /etc/patchbay/patchbay.db \"
INSERT INTO nodes (id, type, client_id, host, port, proto, iface, origin) VALUES
(40, 'client_source', 1, '127.0.0.1', 9400, 'tcp', '', ''),
(41, 'splitter', NULL, '', 0, 'tcp', '', ''),
(42, 'public_sink', NULL, '0.0.0.0', 2220, 'tcp', '', 'proxy_v2'),
(43, 'client_sink', 2, '127.0.0.1', 7400, 'tcp', '', 'proxy_v2'),
(44, 'client_source', 1, '127.0.0.1', 9401, 'udp', '', ''),
(45, 'public_sink', NULL, '0.0.0.0', 2221, 'udp', '', 'proxy_v2'),
(46, 'tunnel_source', NULL, '10.77.0.2', 9402, 'tcp', 'tun7', ''),
(47, 'public_sink', NULL, '0.0.0.0', 2224, 'tcp', '', 'proxy_v2'),
(48, 'client_source', 1, '127.0.0.1', 9410, 'tcp', '', ''),
(49, 'public_sink', NULL, '0.0.0.0', 2230, 'tcp', '', 'transparent'),
(50, 'client_source', 1, '127.0.0.1', 9411, 'udp', '', ''),
(51, 'public_sink', NULL, '0.0.0.0', 2231, 'udp', '', 'transparent'),
(52, 'tunnel_source', NULL, '10.77.0.2', 9402, 'tcp', 'tun7', ''),
(53, 'public_sink', NULL, '0.0.0.0', 2232, 'tcp', '', 'transparent');
INSERT INTO links (from_node, to_node) VALUES (40, 41), (41, 42), (41, 43), (44, 45), (46, 47), (48, 49),
(50, 51), (52, 53);\""
on target 'echo RELOAD | socat - UNIX-CONNECT:/run/patchbay/api.sock' >/dev/null
sleep 3
c2ip=$(on target 'getent ahostsv4 client2 | head -1 | cut -d" " -f1')
c2hex=$(printf '%02x' $(echo "$c2ip" | tr . ' '))
sig=0d0a0d0a000d0a515549540a
r=$(on client2 'echo x | socat -t3 - TCP:target:2220')
[ "${r:0:32}" = "${sig}2111000c" ] && [ "${r:32:8}" = "$c2hex" ] && ok "PROXY v2 via public sink (TCP)" || bad "PROXY v2 public TCP: got '$r', want src $c2hex"
r=$(on client2 'echo x | socat -t3 - TCP:127.0.0.1:7400')
[ "${r:0:32}" = "${sig}2111000c" ] && [ "${r:32:8}" = 7f000001 ] && ok "PROXY v2 via client sink" || bad "PROXY v2 client sink: got '$r'"
r=$(on client2 'echo x | socat -T3 - UDP:target:2221')
[ "${r:0:32}" = "${sig}2112000c" ] && [ "${r:32:8}" = "$c2hex" ] && ok "PROXY v2 via public sink (UDP)" || bad "PROXY v2 public UDP: got '$r'"
r=$(on client2 'echo x | socat -t3 - TCP:target:2224')
[ "${r:0:32}" = "${sig}2111000c" ] && [ "${r:32:8}" = "$c2hex" ] && ok "PROXY v2 from target tunnel source" || bad "PROXY v2 target source: got '$r'"
r=$(on client2 'echo x | socat -t3 - TCP:target:2230')
[ "${r%%:*}" = "$c2ip" ] && ok "transparent spoofing (TCP): service sees $r" || bad "spoofing TCP: service saw '$r', want $c2ip"
r=$(on client2 'echo x | socat -T3 - UDP:target:2231')
[ "${r%%:*}" = "$c2ip" ] && ok "transparent spoofing (UDP): service sees $r" || bad "spoofing UDP: service saw '$r', want $c2ip"
r=$(on target 'echo STATUS | socat - UNIX-CONNECT:/run/patchbay/api.sock')
echo "$r" | grep -q '"53":{"ok":false,[^}]*needs a source on a client' && ok "spoofing refused for a target source" || bad "spoofing on target source: $r"
# Web login over HTTPS with the emailed (logged) code. # Web login over HTTPS with the emailed (logged) code.
r=$(on target 'set -e r=$(on target 'set -e
J=/tmp/jar; rm -f $J; U=https://127.0.0.1:8443 J=/tmp/jar; rm -f $J; U=https://127.0.0.1:8443

View File

@@ -12,4 +12,8 @@ TargetPort = 2222
# The target's host key is trusted on first connect and pinned here. # The target's host key is trusted on first connect and pinned here.
# KnownHosts = /etc/patchbay/known_hosts # KnownHosts = /etc/patchbay/known_hosts
# ServicesInterval = 30 # ServicesInterval = 30
# Transparent source spoofing: routing table (and rule priority) patchbayd uses
# to route replies from loopback services back to itself. 0 = set it up by hand.
# TransparentTable = 470
# LogLevel = info # LogLevel = info

View File

@@ -47,7 +47,9 @@ CREATE TABLE IF NOT EXISTS clients (
-- Patch graph. type: client_source, client_sink, public_sink, splitter, -- Patch graph. type: client_source, client_sink, public_sink, splitter,
-- tunnel_source, tunnel_sink. host: service address (sources) / bind address -- tunnel_source, tunnel_sink. host: service address (sources) / bind address
-- (sinks). Tunnel nodes use iface; their client_id NULL means the target. -- (sinks). Tunnel nodes use iface; their client_id NULL means the target.
-- Databases created before iface existed are migrated by both programs. -- origin (sinks): '' | 'proxy_v2' (PROXY v2 header to the service) |
-- 'transparent' (source spoofing, source must be on a client).
-- Databases created before iface/origin existed are migrated by both programs.
CREATE TABLE IF NOT EXISTS nodes ( CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
type TEXT NOT NULL, type TEXT NOT NULL,
@@ -57,6 +59,7 @@ CREATE TABLE IF NOT EXISTS nodes (
proto TEXT NOT NULL DEFAULT 'tcp', proto TEXT NOT NULL DEFAULT 'tcp',
label TEXT NOT NULL DEFAULT '', label TEXT NOT NULL DEFAULT '',
iface TEXT NOT NULL DEFAULT '', iface TEXT NOT NULL DEFAULT '',
origin TEXT NOT NULL DEFAULT '',
x REAL NOT NULL DEFAULT 0, x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0 y REAL NOT NULL DEFAULT 0
); );

View File

@@ -27,7 +27,7 @@ def _clients():
def graph_get(): def graph_get():
conn = db.get() conn = db.get()
nodes = [dict(r) for r in conn.execute( nodes = [dict(r) for r in conn.execute(
"SELECT id, type, client_id, host, port, proto, label, iface, x, y FROM nodes ORDER BY id")] "SELECT id, type, client_id, host, port, proto, label, iface, origin, x, y FROM nodes ORDER BY id")]
links = [{"from": r["from_node"], "to": r["to_node"]} for r in conn.execute( links = [{"from": r["from_node"], "to": r["to_node"]} for r in conn.execute(
"SELECT from_node, to_node FROM links")] "SELECT from_node, to_node FROM links")]
return jsonify(nodes=nodes, links=links, clients=_clients(), interfaces=_interfaces()) return jsonify(nodes=nodes, links=links, clients=_clients(), interfaces=_interfaces())
@@ -58,7 +58,7 @@ def graph_put():
keep = {n["id"] for n in nodes if n["id"] > 0} keep = {n["id"] for n in nodes if n["id"] > 0}
for nid in existing - keep: for nid in existing - keep:
conn.execute("DELETE FROM nodes WHERE id = ?", (nid,)) conn.execute("DELETE FROM nodes WHERE id = ?", (nid,))
cols = ("type", "client_id", "host", "port", "proto", "label", "iface", "x", "y") cols = ("type", "client_id", "host", "port", "proto", "label", "iface", "origin", "x", "y")
for n in nodes: for n in nodes:
vals = [n[c] for c in cols] vals = [n[c] for c in cols]
if n["id"] > 0 and n["id"] in existing: if n["id"] > 0 and n["id"] in existing:

View File

@@ -34,10 +34,11 @@ def connect(path):
def init(path): def init(path):
conn = connect(path) conn = connect(path)
conn.executescript(schema_sql()) conn.executescript(schema_sql())
# Databases from before tunnel nodes lack nodes.iface (same migration as db.c). # Older databases lack nodes.iface / nodes.origin (same migrations as db.c).
cols = {r["name"] for r in conn.execute("PRAGMA table_info(nodes)")} cols = {r["name"] for r in conn.execute("PRAGMA table_info(nodes)")}
if "iface" not in cols: for col in ("iface", "origin"):
conn.execute("ALTER TABLE nodes ADD COLUMN iface TEXT NOT NULL DEFAULT ''") if col not in cols:
conn.execute(f"ALTER TABLE nodes ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
conn.close() conn.close()

View File

@@ -4,6 +4,7 @@
(() => { (() => {
const NODE_W = 220; const NODE_W = 220;
const SOCKET_Y = 47; // socket centre below the node's top edge const SOCKET_Y = 47; // socket centre below the node's top edge
const GRID = 24; // matches the editor background
const TYPES = { const TYPES = {
client_source: { title: "Client Source", input: false, output: true }, client_source: { title: "Client Source", input: false, output: true },
client_sink: { title: "Client Sink", input: true, output: false }, client_sink: { title: "Client Sink", input: true, output: false },
@@ -32,7 +33,8 @@
daemon: true, daemon: true,
pan: { x: 40, y: 40 }, pan: { x: 40, y: 40 },
zoom: 1, zoom: 1,
sel: null, // {kind: "node", id} | {kind: "link", link} sel: null, // {kind: "nodes", ids: Set} | {kind: "link", link}
tidy: false, // snap nodes to the grid
nextTemp: -1, nextTemp: -1,
dirty: false, dirty: false,
saving: false, saving: false,
@@ -79,6 +81,13 @@
const t = `translate(${S.pan.x}px, ${S.pan.y}px) scale(${S.zoom})`; const t = `translate(${S.pan.x}px, ${S.pan.y}px) scale(${S.zoom})`;
canvas.style.transform = t; canvas.style.transform = t;
wireLayer.setAttribute("transform", `translate(${S.pan.x} ${S.pan.y}) scale(${S.zoom})`); wireLayer.setAttribute("transform", `translate(${S.pan.x} ${S.pan.y}) scale(${S.zoom})`);
// The grid moves with the canvas so tidy nodes sit on its lines.
editor.style.backgroundSize = `${GRID * S.zoom}px ${GRID * S.zoom}px`;
editor.style.backgroundPosition = `${S.pan.x}px ${S.pan.y}px`;
}
function snap(v) {
return Math.round(v / GRID) * GRID;
} }
function toCanvas(clientX, clientY) { function toCanvas(clientX, clientY) {
@@ -242,6 +251,40 @@
return sel; return sel;
} }
// How a sink passes the peer address on. Both options are exclusive, and
// spoofing needs the source on a client (the daemon reports it otherwise).
function originChecks(n) {
const box = (label, value, title) => {
const inp = PB.el("input", { type: "checkbox" });
const lab = PB.el("label", { class: "check", title }, inp, PB.el("span", { text: label }));
inp.addEventListener("change", () => {
n.origin = inp.checked ? value : "";
update();
changed();
});
return { inp, lab, value };
};
const proxy = box("PROXY v2", "proxy_v2",
"Send a PROXY protocol v2 header with the peer address to the service (e.g. Apache mod_remoteip, nginx proxy_protocol)");
const spoof = box("Transparent source spoofing", "transparent",
"Connect to the service from the peer's own address (IP_TRANSPARENT); the source must be on a client");
function update() {
const src = sourceOf(n.id);
const onClient = !src || !!src.client_id;
for (const b of [proxy, spoof]) {
b.inp.checked = n.origin === b.value;
let off = !!n.origin && !b.inp.checked;
if (b === spoof && !b.inp.checked && !onClient) off = true;
b.inp.disabled = off;
b.lab.classList.toggle("disabled", off);
}
}
update();
const wrap = PB.el("div", { class: "origin" }, proxy.lab, spoof.lab);
wrap._update = update;
return wrap;
}
function buildNode(n) { function buildNode(n) {
const t = TYPES[n.type]; const t = TYPES[n.type];
const el = PB.el("div", { class: `node ${n.type}` }); const el = PB.el("div", { class: `node ${n.type}` });
@@ -265,6 +308,11 @@
body.append(field("Port", portInput(n)), field("Proto", protoSelect(n))); body.append(field("Port", portInput(n)), field("Proto", protoSelect(n)));
} }
body.append(field("Label", textInput(n, "label", "optional"))); body.append(field("Label", textInput(n, "label", "optional")));
let origin = null;
if (t.input && n.type !== "splitter") {
origin = originChecks(n);
body.append(origin);
}
const status = PB.el("div", { class: "node-status" }); const status = PB.el("div", { class: "node-status" });
body.append(status); body.append(status);
el.append(head, body); el.append(head, body);
@@ -275,13 +323,15 @@
close.addEventListener("click", () => removeNode(n.id)); close.addEventListener("click", () => removeNode(n.id));
head.addEventListener("mousedown", (ev) => startNodeDrag(ev, n)); head.addEventListener("mousedown", (ev) => startNodeDrag(ev, n));
el.addEventListener("mousedown", (ev) => { el.addEventListener("mousedown", (ev) => {
// Sockets must bubble up to the canvas handler that starts wire drags. // Sockets must bubble up to the canvas handler that starts wire drags,
if (ev.target.closest("input, select, button, .socket")) return; // right clicks to the editor that draws the selection box.
if (ev.button === 2 || ev.target.closest("input, select, button, .socket")) return;
ev.stopPropagation(); ev.stopPropagation();
select({ kind: "node", id: n.id }); if (!isSelected(n.id)) selectNodes([n.id]);
}); });
el._status = status; el._status = status;
el._title = title; el._title = title;
el._origin = origin;
return el; return el;
} }
@@ -308,19 +358,23 @@
function addNode(type) { function addNode(type) {
const r = editor.getBoundingClientRect(); const r = editor.getBoundingClientRect();
const c = toCanvas(r.left + r.width / 2, r.top + r.height / 2); const c = toCanvas(r.left + r.width / 2, r.top + r.height / 2);
const off = (S.addOffset++ % 6) * 24; const off = (S.addOffset++ % 6) * GRID;
const n = { const n = {
id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "", id: S.nextTemp--, type, client_id: null, port: 0, proto: "tcp", label: "", origin: "",
iface: TUNNEL_TYPES.has(type) ? "tun0" : "", iface: TUNNEL_TYPES.has(type) ? "tun0" : "",
host: type === "public_sink" ? "0.0.0.0" : (type === "splitter" || TUNNEL_TYPES.has(type)) ? "" : "127.0.0.1", 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), x: Math.round(c.x - NODE_W / 2 + off), y: Math.round(c.y - 80 + off),
}; };
if (S.tidy) {
n.x = snap(n.x);
n.y = snap(n.y);
}
S.nodes.set(n.id, n); S.nodes.set(n.id, n);
const el = buildNode(n); const el = buildNode(n);
S.els.set(n.id, el); S.els.set(n.id, el);
canvas.append(el); canvas.append(el);
placeNode(n); placeNode(n);
select({ kind: "node", id: n.id }); selectNodes([n.id]);
changed(); changed();
} }
@@ -330,17 +384,28 @@
if (el) el.remove(); if (el) el.remove();
S.els.delete(id); S.els.delete(id);
S.links = S.links.filter((l) => l.from !== id && l.to !== id); S.links = S.links.filter((l) => l.from !== id && l.to !== id);
if (S.sel && S.sel.kind === "node" && S.sel.id === id) S.sel = null; if (isSelected(id)) {
S.sel.ids.delete(id);
if (!S.sel.ids.size) S.sel = null;
}
drawWires(); drawWires();
changed(); changed();
} }
function isSelected(id) {
return !!S.sel && S.sel.kind === "nodes" && S.sel.ids.has(id);
}
function select(sel) { function select(sel) {
S.sel = sel; S.sel = sel;
for (const [id, el] of S.els) el.classList.toggle("selected", !!sel && sel.kind === "node" && sel.id === id); for (const [id, el] of S.els) el.classList.toggle("selected", isSelected(id));
drawWires(); drawWires();
} }
function selectNodes(ids) {
select(ids.length ? { kind: "nodes", ids: new Set(ids) } : null);
}
/* Status */ /* Status */
function updateStatus() { function updateStatus() {
@@ -348,6 +413,7 @@
const el = S.els.get(n.id); const el = S.els.get(n.id);
if (!el) continue; if (!el) continue;
el._title.textContent = TYPES[n.type].title + (n.id > 0 ? ` #${n.id}` : ""); el._title.textContent = TYPES[n.type].title + (n.id > 0 ? ` #${n.id}` : "");
if (el._origin) el._origin._update();
const st = el._status; const st = el._status;
st.replaceChildren(); st.replaceChildren();
st.classList.remove("err"); st.classList.remove("err");
@@ -447,7 +513,10 @@
if (l.from === oldId) l.from = newId; if (l.from === oldId) l.from = newId;
if (l.to === oldId) l.to = newId; if (l.to === oldId) l.to = newId;
} }
if (S.sel && S.sel.kind === "node" && S.sel.id === oldId) S.sel.id = newId; if (isSelected(oldId)) {
S.sel.ids.delete(oldId);
S.sel.ids.add(newId);
}
any = true; any = true;
} }
if (any) renderAll(); if (any) renderAll();
@@ -459,16 +528,26 @@
if (ev.button !== 0 || ev.target.closest("button")) return; if (ev.button !== 0 || ev.target.closest("button")) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
select({ kind: "node", id: n.id }); // Dragging a member of a multi-selection moves the whole group.
if (!isSelected(n.id)) selectNodes([n.id]);
const group = [...S.sel.ids].map((id) => S.nodes.get(id)).filter(Boolean).map((g) => ({ g, x: g.x, y: g.y }));
const start = toCanvas(ev.clientX, ev.clientY); const start = toCanvas(ev.clientX, ev.clientY);
const ox = n.x, oy = n.y; const ox = n.x, oy = n.y;
let moved = false; let moved = false;
const move = (e) => { const move = (e) => {
const p = toCanvas(e.clientX, e.clientY); const p = toCanvas(e.clientX, e.clientY);
n.x = Math.round(ox + p.x - start.x); let dx = Math.round(p.x - start.x), dy = Math.round(p.y - start.y);
n.y = Math.round(oy + p.y - start.y); // Tidy snaps the grabbed node; the others keep their offset to it.
if (S.tidy) {
dx = snap(ox + dx) - ox;
dy = snap(oy + dy) - oy;
}
for (const m of group) {
m.g.x = m.x + dx;
m.g.y = m.y + dy;
placeNode(m.g);
}
moved = true; moved = true;
placeNode(n);
drawWires(); drawWires();
}; };
const up = () => { const up = () => {
@@ -543,7 +622,52 @@
} }
}); });
// Right-drag draws a selection box; nodes touching it are selected.
// Shift keeps the current selection and adds to it.
function startMarquee(ev) {
ev.preventDefault();
editor.focus();
const r = editor.getBoundingClientRect();
const sx = ev.clientX - r.left, sy = ev.clientY - r.top;
const base = ev.shiftKey && S.sel && S.sel.kind === "nodes" ? [...S.sel.ids] : [];
const box = PB.el("div", { class: "marquee" });
editor.append(box);
const move = (e) => {
const x = e.clientX - r.left, y = e.clientY - r.top;
const x0 = Math.min(sx, x), y0 = Math.min(sy, y), w = Math.abs(x - sx), h = Math.abs(y - sy);
box.style.left = x0 + "px";
box.style.top = y0 + "px";
box.style.width = w + "px";
box.style.height = h + "px";
const a = toCanvas(r.left + x0, r.top + y0), b = toCanvas(r.left + x0 + w, r.top + y0 + h);
const ids = new Set(base);
for (const n of S.nodes.values()) {
const el = S.els.get(n.id);
const nh = el ? el.offsetHeight : 150;
if (n.x < b.x && n.x + NODE_W > a.x && n.y < b.y && n.y + nh > a.y) ids.add(n.id);
}
selectNodes([...ids]);
};
const up = (e) => {
if (e.button !== 2) return;
box.remove();
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
};
move(ev);
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
editor.addEventListener("contextmenu", (ev) => {
if (!ev.target.closest("input, select, textarea")) ev.preventDefault();
});
editor.addEventListener("mousedown", (ev) => { editor.addEventListener("mousedown", (ev) => {
if (ev.button === 2) {
if (!ev.target.closest("input, select, textarea")) startMarquee(ev);
return;
}
if (ev.button !== 0 && ev.button !== 1) return; if (ev.button !== 0 && ev.button !== 1) return;
if (ev.target.closest(".node")) return; if (ev.target.closest(".node")) return;
ev.preventDefault(); ev.preventDefault();
@@ -581,7 +705,7 @@
if (ev.target.closest("input, select, textarea")) return; if (ev.target.closest("input, select, textarea")) return;
if ((ev.key === "Delete" || ev.key === "Backspace") && S.sel) { if ((ev.key === "Delete" || ev.key === "Backspace") && S.sel) {
ev.preventDefault(); ev.preventDefault();
if (S.sel.kind === "node") removeNode(S.sel.id); if (S.sel.kind === "nodes") [...S.sel.ids].forEach(removeNode);
else removeLink(S.sel.link); else removeLink(S.sel.link);
} }
}); });
@@ -603,6 +727,28 @@
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") setMenu(false); }); document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") setMenu(false); });
document.getElementById("fit-btn").addEventListener("click", fit); document.getElementById("fit-btn").addEventListener("click", fit);
// Tidy: snaps every node to the grid when switched on and keeps drags and
// new nodes on it. The switch is remembered per browser.
const tidyBtn = document.getElementById("tidy-btn");
function setTidy(on, apply) {
S.tidy = on;
tidyBtn.setAttribute("aria-pressed", String(on));
try { localStorage.setItem("pb-tidy", on ? "1" : "0"); } catch (e) { /* storage blocked */ }
if (!on || !apply) return;
let moved = false;
for (const n of S.nodes.values()) {
const x = snap(n.x), y = snap(n.y);
if (x === n.x && y === n.y) continue;
n.x = x;
n.y = y;
placeNode(n);
moved = true;
}
if (moved) changed();
}
tidyBtn.addEventListener("click", () => setTidy(!S.tidy, true));
try { setTidy(localStorage.getItem("pb-tidy") === "1", false); } catch (e) { /* storage blocked */ }
window.addEventListener("beforeunload", (ev) => { window.addEventListener("beforeunload", (ev) => {
if (S.dirty || S.saving) { if (S.dirty || S.saving) {
save(); save();

View File

@@ -68,6 +68,7 @@ h3 { font-size: 14px; margin: 18px 0 8px; }
.btn.primary:hover { filter: brightness(1.1); } .btn.primary:hover { filter: brightness(1.1); }
.btn.danger { color: #ff8a8a; } .btn.danger { color: #ff8a8a; }
.btn.small { padding: 3px 10px; font-size: 12.5px; } .btn.small { padding: 3px 10px; font-size: 12.5px; }
.btn[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
input, select, textarea { input, select, textarea {
font: inherit; color: var(--text-primary); background: var(--surface-1); font: inherit; color: var(--text-primary); background: var(--surface-1);
border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; width: 100%; border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; width: 100%;
@@ -138,6 +139,10 @@ input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; te
#wires .wire-del path { stroke: #fff; stroke-width: 2; pointer-events: none; } #wires .wire-del path { stroke: #fff; stroke-width: 2; pointer-events: none; }
#wires .wire-del:hover circle { fill: var(--danger); } #wires .wire-del:hover circle { fill: var(--danger); }
#canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; } #canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.marquee {
position: absolute; z-index: 5; pointer-events: none;
border: 1px solid #f08a24; background: rgba(240, 138, 36, .15); border-radius: 2px;
}
.editor-hint { .editor-hint {
position: absolute; left: 12px; bottom: 10px; color: var(--text-muted); font-size: 12px; 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; background: rgba(26, 26, 25, .85); padding: 4px 8px; border-radius: 4px; pointer-events: none;
@@ -168,6 +173,11 @@ input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; te
.node-body label { flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; } .node-body label { 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 label > span { flex: none; width: 54px; }
.node-body input, .node-body select { padding: 3px 6px; font-size: 12.5px; } .node-body input, .node-body select { padding: 3px 6px; font-size: 12.5px; }
.node-body .origin { display: flex; flex-direction: column; gap: 4px; }
.node-body label.check { justify-content: flex-start; cursor: pointer; }
.node-body label.check > span { width: auto; }
.node-body label.check input { width: auto; flex: none; margin: 0; accent-color: var(--accent); }
.node-body label.check.disabled { opacity: 0.5; cursor: default; }
.node-status { font-size: 11.5px; color: var(--text-muted); min-height: 16px; display: flex; gap: 6px; align-items: center; } .node-status { font-size: 11.5px; color: var(--text-muted); min-height: 16px; display: flex; gap: 6px; align-items: center; }
.node-status.err { color: #ff8a8a; } .node-status.err { color: #ff8a8a; }
.socket { .socket {
@@ -193,6 +203,26 @@ input.code { font-family: var(--mono); font-size: 22px; letter-spacing: .3em; te
.save-state { color: var(--text-muted); font-size: 12.5px; min-width: 120px; } .save-state { color: var(--text-muted); font-size: 12.5px; min-width: 120px; }
.save-state.err { color: #ff8a8a; } .save-state.err { color: #ff8a8a; }
/* Help */
.help-toc { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 13.5px; }
.help { line-height: 1.55; }
.help p, .help ul, .help ol { margin: 0 0 10px; }
.help p:last-child, .help ul:last-child, .help ol:last-child { margin-bottom: 0; }
.help li + li { margin-top: 6px; }
.help-table { border-collapse: collapse; width: 100%; margin-bottom: 10px; }
.help-table td { padding: 7px 10px 7px 0; border-top: 1px solid var(--border); vertical-align: top; }
.help-table td:first-child { white-space: nowrap; font-weight: 600; width: 1%; }
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; }
.swatch.client_source { background: var(--node-source); }
.swatch.client_sink { background: var(--node-csink); }
.swatch.public_sink { background: var(--node-psink); }
.swatch.splitter { background: var(--node-split); }
.swatch.tunnel_source { background: var(--node-tsource); }
.swatch.tunnel_sink { background: var(--node-tsink); }
@media (max-width: 640px) {
.help-table td:first-child { white-space: normal; }
}
/* Stats */ /* Stats */
.filter-row { display: flex; gap: 16px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; } .filter-row { display: flex; gap: 16px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; }
.filter-row label { min-width: 280px; flex: 1; } .filter-row label { min-width: 280px; flex: 1; }

View File

@@ -17,6 +17,7 @@
<a href="{{ url_for('views.services') }}" class="{{ 'active' if ep == 'views.services' }}">Services</a> <a href="{{ url_for('views.services') }}" class="{{ 'active' if ep == 'views.services' }}">Services</a>
<a href="{{ url_for('views.stats') }}" class="{{ 'active' if ep == 'views.stats' }}">Stats</a> <a href="{{ url_for('views.stats') }}" class="{{ 'active' if ep == 'views.stats' }}">Stats</a>
<a href="{{ url_for('views.settings') }}" class="{{ 'active' if ep == 'views.settings' }}">Settings</a> <a href="{{ url_for('views.settings') }}" class="{{ 'active' if ep == 'views.settings' }}">Settings</a>
<a href="{{ url_for('views.help_page') }}" class="{{ 'active' if ep == 'views.help_page' }}">Help</a>
</nav> </nav>
{% block toolbar %}{% endblock %} {% block toolbar %}{% endblock %}
<form method="post" action="{{ url_for('auth.logout') }}" class="logout"> <form method="post" action="{{ url_for('auth.logout') }}" class="logout">

View File

@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% block title %}Help{% endblock %}
{% block content %}
<h1>Help</h1>
<nav class="help-toc" aria-label="Contents">
<a href="#overview">Overview</a>
<a href="#start">Getting started</a>
<a href="#editor">Patch editor</a>
<a href="#nodes">Node types</a>
<a href="#origin">Visitor addresses</a>
<a href="#pages">Services and Stats</a>
<a href="#settings">Settings and login</a>
<a href="#trouble">Status messages</a>
</nav>
<section class="card help" id="overview">
<h2>Overview</h2>
<p>PatchBay forwards ports between Linux machines over SSH. One machine is the <strong>target</strong>: it runs this web UI and is reachable from the internet. Every other machine is a <strong>client</strong>: it keeps an SSH connection open to the target, so it needs no open ports and can sit behind NAT.</p>
<p>You decide what goes where by wiring nodes on the Patch page, like cables on a patch panel. A <strong>source</strong> is a service you want to reach (for example a web server on a client), a <strong>sink</strong> is where it shows up (for example a public port on the target). All traffic passes through the target, which counts it for the Stats page.</p>
</section>
<section class="card help" id="start">
<h2>Getting started</h2>
<ol>
<li>Install a client with <code>./install.sh --role client</code> and set <code>TargetHost</code> (and <code>TargetPort</code>) in <code>/etc/patchbay/patchbay.conf</code>.</li>
<li>On the client, run <code>patchbayd --pubkey</code> and paste the output under <a href="{{ url_for('views.settings') }}">Settings</a> &rarr; Clients.</li>
<li>Start <code>patchbayd</code> on the client. It shows as online on the Patch and Services pages within a few seconds.</li>
<li>On the <a href="{{ url_for('views.patch') }}">Patch</a> page, add a Client Source for the service and a Public Sink for the port you want to open, then drag a wire from the source's output to the sink's input. That's it: the port is live as soon as the status line says "ready".</li>
</ol>
</section>
<section class="card help" id="editor">
<h2>Patch editor</h2>
<ul>
<li><strong>Add</strong> creates a node in the middle of the view. Fill in its fields; changes are saved automatically and applied straight away.</li>
<li><strong>Wiring:</strong> drag from an output socket (right side) to an input socket (left side). Grab a connected input to move its wire elsewhere. Click a wire and press its &times; button, or double-click it, to remove it.</li>
<li><strong>Wire colours:</strong> blue is TCP, orange is UDP. A dashed wire means source and sink use different protocols and will not work.</li>
<li><strong>Moving around:</strong> drag empty space to pan, scroll to zoom, <strong>Fit</strong> shows everything.</li>
<li><strong>Selecting:</strong> click a node, or right-drag a box over several nodes (hold Shift to add to the selection). Drag the header of any selected node to move them all. Delete removes the selected nodes.</li>
<li><strong>Tidy</strong> snaps all nodes to the grid and keeps them on it while it is switched on.</li>
<li>Each sink shows its state at the bottom: live traffic and open connections, or what is wrong (see <a href="#trouble">Status messages</a>).</li>
</ul>
</section>
<section class="card help" id="nodes">
<h2>Node types</h2>
<table class="help-table">
<tbody>
<tr><td><span class="swatch client_source"></span>Client Source</td><td>A service on a client, e.g. <code>127.0.0.1:80</code> for a local web server. The address is as seen from that client, so it can also be another machine in the client's network.</td></tr>
<tr><td><span class="swatch public_sink"></span>Public Sink</td><td>A port opened on the target. Bind <code>0.0.0.0</code> makes it reachable from everywhere, <code>127.0.0.1</code> only from the target itself.</td></tr>
<tr><td><span class="swatch client_sink"></span>Client Sink</td><td>A port opened on a client. Use it to reach a service on one client from another client, without exposing it publicly.</td></tr>
<tr><td><span class="swatch splitter"></span>Splitter</td><td>Sends one source to several sinks, e.g. the same service on a public port and on a client. A source has only one output, so use a splitter to fan out.</td></tr>
<tr><td><span class="swatch tunnel_source"></span>Tunnel Source</td><td>A host behind a VPN interface (<code>tun0</code> etc.), on the target or on a client. Enter the interface and the peer's address inside the VPN.</td></tr>
<tr><td><span class="swatch tunnel_sink"></span>Tunnel Sink</td><td>A port that only accepts connections arriving through a VPN interface, so VPN peers can reach a service that is not open anywhere else.</td></tr>
</tbody>
</table>
<p class="muted">Label is a free-text name shown in Stats. Interface suggestions come from the tun interfaces each host reports.</p>
</section>
<section class="card help" id="origin">
<h2>Visitor addresses</h2>
<p>Normally a service behind PatchBay sees every visitor as coming from PatchBay itself (for example <code>127.0.0.1</code>). Every sink has two optional checkboxes to pass the real address on. Only one can be on at a time.</p>
<ul>
<li><strong>PROXY v2</strong> puts a small header with the visitor's address in front of each connection. The service must understand the PROXY protocol, otherwise it sees garbage and drops the connection. Examples: Apache with <code>mod_remoteip</code> and <code>RemoteIPProxyProtocol On</code>, nginx with <code>listen ... proxy_protocol</code>, HAProxy, Postfix, Dovecot. For UDP the header is in front of every datagram.</li>
<li><strong>Transparent source spoofing</strong> makes the connection arrive from the visitor's own address, so any program sees it without configuration. The source must be on a client, and the service should listen on a loopback address like <code>127.0.0.1</code>. PatchBay sets up the routing this needs on the client by itself (setting <code>TransparentTable</code>, default 470). If the visitor uses IPv6 and the service only IPv4, that connection falls back to the normal address.</li>
</ul>
</section>
<section class="card help" id="pages">
<h2>Services and Stats</h2>
<p><a href="{{ url_for('views.services') }}">Services</a> lists the listening ports on every host with the program behind them, which helps to fill in source addresses. Use Refresh now to ask all hosts for a fresh list.</p>
<p><a href="{{ url_for('views.stats') }}">Stats</a> shows traffic per sink: current rates, open connections and history from the last hour up to all time. "In" is traffic towards the service, "out" is traffic back to the visitor. How long history is kept is set under Settings.</p>
</section>
<section class="card help" id="settings">
<h2>Settings and login</h2>
<ul>
<li>Logging in takes your password plus a 6-digit code sent by email. Too many failed attempts block your address for a while.</li>
<li>All users can edit the patch and add or remove clients. The <strong>sysop</strong> (the account in <code>patchbay.conf</code>) also manages users, the mail server and statistics retention.</li>
<li>Removing a client disconnects it at once; its nodes stay in the patch without a client until you pick another one.</li>
</ul>
</section>
<section class="card help" id="trouble">
<h2>Status messages</h2>
<table class="help-table">
<tbody>
<tr><td>not connected</td><td>The sink has no wire into its input.</td></tr>
<tr><td>source offline</td><td>The client with the source is not connected right now. Check that <code>patchbayd</code> runs there and its key is listed under Settings.</td></tr>
<tr><td>protocol mismatch</td><td>Source and sink use different protocols (TCP vs UDP).</td></tr>
<tr><td>bind ... failed</td><td>The port is already used by another program, or the bind address does not exist on that host.</td></tr>
<tr><td>interface not present</td><td>The tun interface does not exist (yet). PatchBay retries every few seconds, so this clears once the VPN is up.</td></tr>
<tr><td>transparent spoofing needs a source on a client</td><td>Spoofing only works when the service is reached from a client. Use PROXY v2 instead, or move the source.</td></tr>
<tr><td>daemon not reachable</td><td>The web UI cannot talk to <code>patchbayd</code> on the target. Changes are saved and applied once it runs again.</td></tr>
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -15,6 +15,7 @@
</div> </div>
</div> </div>
<button type="button" class="btn" id="fit-btn" title="Fit all nodes into view">Fit</button> <button type="button" class="btn" id="fit-btn" title="Fit all nodes into view">Fit</button>
<button type="button" class="btn" id="tidy-btn" aria-pressed="false" title="Snap all nodes to the grid">Tidy</button>
<span id="save-state" class="save-state"></span> <span id="save-state" class="save-state"></span>
</div> </div>
{% endblock %} {% endblock %}
@@ -22,7 +23,7 @@
<div id="editor" tabindex="0"> <div id="editor" tabindex="0">
<svg id="wires" aria-hidden="true"><g id="wire-layer"></g></svg> <svg id="wires" aria-hidden="true"><g id="wire-layer"></g></svg>
<div id="canvas"></div> <div id="canvas"></div>
<div class="editor-hint">Drag from an output socket to an input socket to patch. Drag empty space to pan, scroll to zoom. Click a wire and use its x button (or double-click it) to remove it; drag a wire off an input to reconnect it. Selected nodes are removed with Delete.</div> <div class="editor-hint">Drag from an output socket to an input socket to patch. Drag empty space to pan, scroll to zoom. Right-drag to select several nodes, then drag one of them to move them together. Click a wire and use its x button (or double-click it) to remove it; drag a wire off an input to reconnect it. Selected nodes are removed with Delete.</div>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View File

@@ -13,6 +13,11 @@ NODE_TYPES = {"client_source", "client_sink", "public_sink", "splitter", "tunnel
SOURCES = {"client_source", "tunnel_source"} SOURCES = {"client_source", "tunnel_source"}
HAS_OUTPUT = SOURCES | {"splitter"} HAS_OUTPUT = SOURCES | {"splitter"}
HAS_INPUT = {"client_sink", "public_sink", "splitter", "tunnel_sink"} HAS_INPUT = {"client_sink", "public_sink", "splitter", "tunnel_sink"}
SINKS = HAS_INPUT - {"splitter"}
# How a sink passes the peer address to the service. Whether "transparent"
# has a source on a client is checked by the daemon (route error), so editing
# the wiring never makes a save fail.
ORIGINS = {"", "proxy_v2", "transparent"}
IFACE_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,15}$") IFACE_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,15}$")
HOST_RE = re.compile(r"^[A-Za-z0-9.:_\-\[\]%]{0,255}$") HOST_RE = re.compile(r"^[A-Za-z0-9.:_\-\[\]%]{0,255}$")
NAME_RE = re.compile(r"^[A-Za-z0-9._\-]{1,64}$") NAME_RE = re.compile(r"^[A-Za-z0-9._\-]{1,64}$")
@@ -106,9 +111,14 @@ def graph(data, client_ids):
if proto not in ("tcp", "udp"): if proto not in ("tcp", "udp"):
raise Invalid("protocol must be tcp or udp") raise Invalid("protocol must be tcp or udp")
label = str(n.get("label") or "")[:64] label = str(n.get("label") or "")[:64]
origin = n.get("origin") or ""
if origin not in ORIGINS:
raise Invalid("origin must be proxy_v2 or transparent")
if t not in SINKS:
origin = ""
nodes.append({ nodes.append({
"id": nid, "type": t, "client_id": cid, "host": host, "port": _port(n.get("port")), "id": nid, "type": t, "client_id": cid, "host": host, "port": _port(n.get("port")),
"proto": proto, "label": label, "iface": iface, "proto": proto, "label": label, "iface": iface, "origin": origin,
"x": float(n.get("x", 0)), "y": float(n.get("y", 0)), "x": float(n.get("x", 0)), "y": float(n.get("y", 0)),
}) })

View File

@@ -24,6 +24,12 @@ def patch():
return render_template("patch.html") return render_template("patch.html")
@bp.get("/help")
@login_required
def help_page():
return render_template("help.html")
@bp.get("/services") @bp.get("/services")
@login_required @login_required
def services(): def services():

View File

@@ -43,6 +43,13 @@ class AuthTest(AppCase):
self.assertEqual(self.client.get("/settings").status_code, 302) self.assertEqual(self.client.get("/settings").status_code, 302)
self.assertEqual(self.client.get("/api/graph").status_code, 401) self.assertEqual(self.client.get("/api/graph").status_code, 401)
def test_help_page(self):
self.assertEqual(self.client.get("/help").status_code, 302)
self.login()
r = self.client.get("/help")
self.assertEqual(r.status_code, 200)
self.assertIn(b"Transparent source spoofing", r.data)
def test_user_management_sysop_only(self): def test_user_management_sysop_only(self):
self.login() self.login()
tok = self.token() tok = self.token()

View File

@@ -48,6 +48,16 @@ class ValidateTest(unittest.TestCase):
n, _ = validate.graph({"nodes": [{"id": -3, "type": "public_sink", "port": 1, "iface": "x"}], "links": []}, {1}) n, _ = validate.graph({"nodes": [{"id": -3, "type": "public_sink", "port": 1, "iface": "x"}], "links": []}, {1})
self.assertEqual(n[0]["iface"], "") self.assertEqual(n[0]["iface"], "")
def test_origin(self):
sink = {"id": -1, "type": "tunnel_sink", "port": 80, "iface": "tun0", "origin": "proxy_v2"}
src = {"id": -2, "type": "client_source", "client_id": 1, "port": 80, "origin": "transparent"}
nodes, _ = validate.graph({"nodes": [sink, src], "links": []}, {1})
self.assertEqual(nodes[0]["origin"], "proxy_v2")
self.assertEqual(nodes[1]["origin"], "") # only sinks carry an origin
with self.assertRaises(validate.Invalid):
validate.graph({"nodes": [dict(sink, origin="both")], "links": []}, {1})
class GraphApiTest(AppCase): class GraphApiTest(AppCase):
def test_roundtrip_keeps_ids(self): def test_roundtrip_keeps_ids(self):
@@ -61,7 +71,8 @@ class GraphApiTest(AppCase):
"nodes": [ "nodes": [
{"id": -1, "type": "client_source", "client_id": cid, "host": "127.0.0.1", "port": 22, "proto": "tcp", "x": 0, "y": 0}, {"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": -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}, {"id": -3, "type": "public_sink", "host": "0.0.0.0", "port": 2200, "proto": "tcp",
"origin": "transparent", "x": 200, "y": 0},
], ],
"links": [{"from": -1, "to": -2}, {"from": -2, "to": -3}], "links": [{"from": -1, "to": -2}, {"from": -2, "to": -3}],
} }
@@ -73,6 +84,7 @@ class GraphApiTest(AppCase):
g = self.client.get("/api/graph").get_json() g = self.client.get("/api/graph").get_json()
self.assertEqual(len(g["nodes"]), 3) self.assertEqual(len(g["nodes"]), 3)
sink_id = ids["-3"] sink_id = ids["-3"]
self.assertEqual(next(n for n in g["nodes"] if n["id"] == sink_id)["origin"], "transparent")
# Saving again with real ids keeps them (stats are keyed by sink id). # Saving again with real ids keeps them (stats are keyed by sink id).
for n in payload["nodes"]: for n in payload["nodes"]:
n["id"] = ids[str(n["id"])] n["id"] = ids[str(n["id"])]