diff --git a/README.md b/README.md index 1f084dc..ba3ddcb 100644 --- a/README.md +++ b/README.md @@ -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://: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. +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 diff --git a/backend/src/client.c b/backend/src/client.c index c7aac14..e3fb443 100644 --- a/backend/src/client.c +++ b/backend/src/client.c @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -10,6 +11,8 @@ #include #include #include +#include +#include #include #include @@ -31,6 +34,7 @@ struct csink { char bind[64]; char iface[IFNAMSIZ]; // tunnel sink: pinned to this interface 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 keep; 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 sockaddr_storage peer; socklen_t peerlen; + unsigned char pp[PROXY_V2_MAX]; // K_SRC_UDP: PROXY header for every datagram + size_t pplen; long last_act; }; @@ -77,6 +83,7 @@ static char token[PB_TOKEN_LEN + 1]; static int hub_port; static int hello_done; 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 volatile sig_atomic_t sig_stop; @@ -416,8 +423,11 @@ static void udp_local_io(struct cc *c) 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); + if (c->kind == K_SRC_UDP && c->fd >= 0) { + 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) sendto(c->sink->fd, payload, plen, MSG_DONTWAIT, (struct sockaddr *)&c->peer, c->peerlen); buf_consume(&c->from_ch, plen + 2); @@ -490,7 +500,7 @@ static void sink_bind(struct csink *s) 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)) 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) && !strcmp(s->iface, iface)) { s->keep = 1; + s->origin = origin; // The hub forgets states on reload; repeat ours. s->state[0] = '\0'; 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->proto = proto; s->port = port; + s->origin = origin; s->fd = -1; snprintf(s->bind, sizeof(s->bind), "%s", bind); 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) { if (s->proto == PROTO_TCP) { 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) return; tune_stream(fd); @@ -574,7 +605,7 @@ static void sink_accept(struct csink *s) close(fd); 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->peer = peer; 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) 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 [ ] +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 */ static void send_services(void) @@ -659,25 +802,11 @@ static void ctl_line(char *line) } 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] : "-"); + 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")) { 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]); + open_source(f, n); } else if (!strcmp(f[0], "SVC-REQ")) { send_services(); } else if (!strcmp(f[0], "PING")) { @@ -1099,6 +1228,8 @@ int client_run(const struct pb_config *conf) usleep(100000); backoff = backoff < 30 ? backoff * 2 : 30; } + if (spoof_routing_done > 0) + spoof_routing(0); libssh2_exit(); return 0; } diff --git a/backend/src/config.c b/backend/src/config.c index 01e83f6..131bfd5 100644 --- a/backend/src/config.c +++ b/backend/src/config.c @@ -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->ssh_user, sizeof(c->ssh_user), "patchbay"); c->services_interval = 30; + c->transparent_table = 470; 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"); @@ -93,6 +94,8 @@ int config_parse_line(struct pb_config *c, char *line) STR(ssh_user); } else if (!strcasecmp(key, "ServicesInterval")) { c->services_interval = atoi(val); + } else if (!strcasecmp(key, "TransparentTable")) { + c->transparent_table = atoi(val); } else if (!strcasecmp(key, "HubPort")) { c->hub_port = atoi(val); } else if (!strcasecmp(key, "SSHHostKey")) { diff --git a/backend/src/config.h b/backend/src/config.h index 3c0bddd..9454aac 100644 --- a/backend/src/config.h +++ b/backend/src/config.h @@ -21,6 +21,7 @@ struct pb_config { char known_hosts[256]; char ssh_user[64]; int services_interval; // seconds between Services reports + int transparent_table; // policy routing table for spoofing, 0 = set up by hand // target role int ssh_port; // dedicated sshd port (same value clients use as TargetPort) diff --git a/backend/src/db.c b/backend/src/db.c index 67f4e6b..01cd4c3 100644 --- a/backend/src/db.c +++ b/backend/src/db.c @@ -32,8 +32,9 @@ sqlite3 *db_open(const char *path) sqlite3_close(db); 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 origin TEXT NOT NULL DEFAULT ''", NULL, NULL, NULL); return db; } diff --git a/backend/src/hub.c b/backend/src/hub.c index 2fe1647..b48daef 100644 --- a/backend/src/hub.c +++ b/backend/src/hub.c @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -40,11 +41,15 @@ enum node_type { 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. struct route { int sink_id; int sink_type; int sink_client; + int origin; char bind[256]; char sink_iface[IFNAMSIZ]; int port; @@ -138,6 +143,11 @@ struct conn { struct sockaddr_storage peer; socklen_t peerlen; 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; }; @@ -178,6 +188,20 @@ static const char *iface_field(const char *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 */ 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. 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, "SINK %d %s %s %d %s %s", r->sink_id, proto_name(r->proto), r->bind, r->port, + iface_field(r->sink_iface), origin_name(r->origin)); } 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->src_client = r->src_client; c->proto = r->proto; + c->origin = r->origin; c->state = CONN_WAIT; c->a_kind = a_kind; 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) 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); + if (c->state == CONN_RELAY) { + // 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; } // 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); 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); } @@ -781,18 +815,30 @@ static void conn_attach_channel(struct conn *c, int fd) // the source is a tunnel source of the target. static int conn_request_source(struct conn *c, struct route *r) { + int origin = c->has_addr ? c->origin : ORIGIN_NONE; 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)); + if (origin == ORIGIN_NONE) { + 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; } + // 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 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) { log_debug("sink %d: connect %s:%d failed: %s", r->sink_id, r->src_host, r->src_port, strerror(errno)); return -1; @@ -824,10 +870,26 @@ static void conn_connected(struct conn *c) /* 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) { 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) return; struct route *r = route_find(ts->sink_id); @@ -842,6 +904,7 @@ static void tsink_accept_tcp(struct tsink *ts) continue; } ev_init(&c->a, EV_CONN_A, fd, c); + conn_set_addr(c, &peer, fd); if (conn_request_source(c, r) < 0) { log_debug("sink %d: source unavailable", ts->sink_id); conn_kill(c); @@ -875,6 +938,8 @@ static void tsink_recv_udp(struct tsink *ts) c->ts = ts; c->peer = peer; 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) { conn_kill(c); continue; @@ -890,7 +955,7 @@ static void tsink_recv_udp(struct tsink *ts) 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); if (n == 0 || (n < 0 && errno != EAGAIN)) { p->dead = 1; @@ -915,7 +980,7 @@ static void pend_read(struct pend *p) 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; + 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 if (!owner) { log_warn("data channel with invalid header rejected"); @@ -949,6 +1014,10 @@ static void pend_read(struct pend *p) return; } 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) { conn_kill(c); return; @@ -962,7 +1031,7 @@ static void pend_read(struct pend *p) /* Patch graph */ struct gnode { - int id, type, client_id, port, proto, input; + int id, type, client_id, port, proto, input, origin; char host[256]; 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"); else if (tunnel && host_empty(cur->host)) 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 { 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); @@ -1038,7 +1109,7 @@ static int load_graph(void) 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"; + "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) { log_err("graph: %s", sqlite3_errmsg(db)); return -1; @@ -1058,6 +1129,7 @@ static int load_graph(void) if (x->iface[0] && !iface_valid(x->iface)) x->iface[0] = '\0'; x->input = sqlite3_column_int(st, 7); + x->origin = origin_parse((const char *)sqlite3_column_text(st, 8)); } 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->proto = g[i].proto; r->port = g[i].port; + r->origin = g[i].origin; 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"); diff --git a/backend/src/net.c b/backend/src/net.c index 335f5d6..f542c2c 100644 --- a/backend/src/net.c +++ b/backend/src/net.c @@ -5,7 +5,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -14,6 +16,13 @@ #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) { if (!strcasecmp(s, "tcp")) @@ -109,21 +118,52 @@ int net_listen(const char *addr, int port, int proto, const char *iface) 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; *in_progress = 0; if (resolve(host, port, proto, 0, &res) < 0) return -1; - int fd = -1, err = 0; + int fd = -1, err = spoof ? EAFNOSUPPORT : 0; 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); if (fd < 0) { err = errno; continue; } - if (bind_device(fd, iface) < 0) { + if (bind_device(fd, iface) < 0 || (spoof && bind_transparent(fd, spoof) < 0)) { err = errno; close(fd); fd = -1; @@ -214,3 +254,106 @@ void sockaddr_str(const struct sockaddr *sa, char *out, size_t 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; +} diff --git a/backend/src/net.h b/backend/src/net.h index 5f2aa65..aab5103 100644 --- a/backend/src/net.h +++ b/backend/src/net.h @@ -1,6 +1,7 @@ #ifndef PB_NET_H #define PB_NET_H +#include #include 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. 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); +// *in_progress set if pending. spoof (may be NULL) is a foreign source address +// 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_connect_unix(const char *path); @@ -25,5 +29,17 @@ 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); +// 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 diff --git a/backend/src/proto.h b/backend/src/proto.h index 9e3ae99..57f5d03 100644 --- a/backend/src/proto.h +++ b/backend/src/proto.h @@ -11,8 +11,8 @@ * (patchbayd --relay) bridges it to the hub socket on the target. * * hub -> client: HELLO - * SINKS-BEGIN / SINK / SINKS-END - * OPEN + * SINKS-BEGIN / SINK [] / SINKS-END + * OPEN [ ] * SVC-REQ, PING * client -> hub: HELLO * SVC-BEGIN / SVC / SVC-END @@ -22,16 +22,22 @@ * * pins a socket to an interface (tunnel nodes, SO_BINDTODEVICE). * + * 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). and + * 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. - * 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_*. + * They start with "PB1 SINK [ ]\n" (a client + * sink accepted a connection; the addresses only for sinks with an origin) 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_MAX_FIELDS 12 #define PB_UDP_MAX 65507 /* Byte buffer */ diff --git a/backend/tests/test_main.c b/backend/tests/test_main.c index 0f56bd5..92403b1 100644 --- a/backend/tests/test_main.c +++ b/backend/tests/test_main.c @@ -1,5 +1,6 @@ /* Minimal unit test runner: ./test_runner [test_name ...] */ +#include #include #include #include @@ -112,6 +113,49 @@ static void test_token(void) 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 */ 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 }, { "json", test_json }, { "token", test_token }, + { "sockaddr", test_sockaddr }, + { "proxy_v2", test_proxy_v2 }, }; int main(int argc, char **argv) diff --git a/docker/run-tests.sh b/docker/run-tests.sh index 7eae00d..3971dbe 100755 --- a/docker/run-tests.sh +++ b/docker/run-tests.sh @@ -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" = 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. r=$(on target 'set -e J=/tmp/jar; rm -f $J; U=https://127.0.0.1:8443 diff --git a/examples/patchbay.conf.client b/examples/patchbay.conf.client index 9917014..bf90d62 100644 --- a/examples/patchbay.conf.client +++ b/examples/patchbay.conf.client @@ -12,4 +12,8 @@ TargetPort = 2222 # The target's host key is trusted on first connect and pinned here. # KnownHosts = /etc/patchbay/known_hosts # 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 diff --git a/schema.sql b/schema.sql index bc43cf0..663e876 100644 --- a/schema.sql +++ b/schema.sql @@ -47,7 +47,9 @@ CREATE TABLE IF NOT EXISTS clients ( -- 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. +-- 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 ( id INTEGER PRIMARY KEY, type TEXT NOT NULL, @@ -57,6 +59,7 @@ CREATE TABLE IF NOT EXISTS nodes ( proto TEXT NOT NULL DEFAULT 'tcp', label TEXT NOT NULL DEFAULT '', iface TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', x REAL NOT NULL DEFAULT 0, y REAL NOT NULL DEFAULT 0 ); diff --git a/web/patchbay_web/api.py b/web/patchbay_web/api.py index d8c6945..d6f85db 100644 --- a/web/patchbay_web/api.py +++ b/web/patchbay_web/api.py @@ -27,7 +27,7 @@ def _clients(): 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")] + "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( "SELECT from_node, to_node FROM links")] 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} for nid in existing - keep: 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: vals = [n[c] for c in cols] if n["id"] > 0 and n["id"] in existing: diff --git a/web/patchbay_web/db.py b/web/patchbay_web/db.py index 447b001..a008338 100644 --- a/web/patchbay_web/db.py +++ b/web/patchbay_web/db.py @@ -34,10 +34,11 @@ def connect(path): def init(path): conn = connect(path) 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)")} - if "iface" not in cols: - conn.execute("ALTER TABLE nodes ADD COLUMN iface TEXT NOT NULL DEFAULT ''") + for col in ("iface", "origin"): + if col not in cols: + conn.execute(f"ALTER TABLE nodes ADD COLUMN {col} TEXT NOT NULL DEFAULT ''") conn.close() diff --git a/web/patchbay_web/static/patch.js b/web/patchbay_web/static/patch.js index f9193b4..1e11cfd 100644 --- a/web/patchbay_web/static/patch.js +++ b/web/patchbay_web/static/patch.js @@ -4,6 +4,7 @@ (() => { const NODE_W = 220; const SOCKET_Y = 47; // socket centre below the node's top edge + const GRID = 24; // matches the editor background const TYPES = { client_source: { title: "Client Source", input: false, output: true }, client_sink: { title: "Client Sink", input: true, output: false }, @@ -32,7 +33,8 @@ daemon: true, pan: { x: 40, y: 40 }, 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, dirty: false, saving: false, @@ -79,6 +81,13 @@ 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})`); + // 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) { @@ -242,6 +251,40 @@ 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) { const t = TYPES[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("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" }); body.append(status); el.append(head, body); @@ -275,13 +323,15 @@ 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; + // Sockets must bubble up to the canvas handler that starts wire drags, + // right clicks to the editor that draws the selection box. + if (ev.button === 2 || ev.target.closest("input, select, button, .socket")) return; ev.stopPropagation(); - select({ kind: "node", id: n.id }); + if (!isSelected(n.id)) selectNodes([n.id]); }); el._status = status; el._title = title; + el._origin = origin; return el; } @@ -308,19 +358,23 @@ 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 off = (S.addOffset++ % 6) * GRID; 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" : "", 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), }; + if (S.tidy) { + n.x = snap(n.x); + n.y = snap(n.y); + } 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 }); + selectNodes([n.id]); changed(); } @@ -330,17 +384,28 @@ 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; + if (isSelected(id)) { + S.sel.ids.delete(id); + if (!S.sel.ids.size) S.sel = null; + } drawWires(); changed(); } + function isSelected(id) { + return !!S.sel && S.sel.kind === "nodes" && S.sel.ids.has(id); + } + function select(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(); } + function selectNodes(ids) { + select(ids.length ? { kind: "nodes", ids: new Set(ids) } : null); + } + /* Status */ function updateStatus() { @@ -348,6 +413,7 @@ const el = S.els.get(n.id); if (!el) continue; el._title.textContent = TYPES[n.type].title + (n.id > 0 ? ` #${n.id}` : ""); + if (el._origin) el._origin._update(); const st = el._status; st.replaceChildren(); st.classList.remove("err"); @@ -447,7 +513,10 @@ 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; + if (isSelected(oldId)) { + S.sel.ids.delete(oldId); + S.sel.ids.add(newId); + } any = true; } if (any) renderAll(); @@ -459,16 +528,26 @@ if (ev.button !== 0 || ev.target.closest("button")) return; ev.preventDefault(); 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 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); + let dx = Math.round(p.x - start.x), dy = Math.round(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; - placeNode(n); drawWires(); }; 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) => { + if (ev.button === 2) { + if (!ev.target.closest("input, select, textarea")) startMarquee(ev); + return; + } if (ev.button !== 0 && ev.button !== 1) return; if (ev.target.closest(".node")) return; ev.preventDefault(); @@ -581,7 +705,7 @@ 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); + if (S.sel.kind === "nodes") [...S.sel.ids].forEach(removeNode); else removeLink(S.sel.link); } }); @@ -603,6 +727,28 @@ document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") setMenu(false); }); 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) => { if (S.dirty || S.saving) { save(); diff --git a/web/patchbay_web/static/style.css b/web/patchbay_web/static/style.css index 249d69d..670edae 100644 --- a/web/patchbay_web/static/style.css +++ b/web/patchbay_web/static/style.css @@ -68,6 +68,7 @@ h3 { font-size: 14px; margin: 18px 0 8px; } .btn.primary:hover { filter: brightness(1.1); } .btn.danger { color: #ff8a8a; } .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 { font: inherit; color: var(--text-primary); background: var(--surface-1); 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:hover circle { fill: var(--danger); } #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 { 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; @@ -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 > span { flex: none; width: 54px; } .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.err { color: #ff8a8a; } .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.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 */ .filter-row { display: flex; gap: 16px; align-items: end; flex-wrap: wrap; margin-bottom: 16px; } .filter-row label { min-width: 280px; flex: 1; } diff --git a/web/patchbay_web/templates/base.html b/web/patchbay_web/templates/base.html index 5f14ebb..eead145 100644 --- a/web/patchbay_web/templates/base.html +++ b/web/patchbay_web/templates/base.html @@ -17,6 +17,7 @@ Services Stats Settings + Help {% block toolbar %}{% endblock %}
diff --git a/web/patchbay_web/templates/help.html b/web/patchbay_web/templates/help.html new file mode 100644 index 0000000..c77f8c4 --- /dev/null +++ b/web/patchbay_web/templates/help.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}Help{% endblock %} +{% block content %} +

Help

+ + +
+

Overview

+

PatchBay forwards ports between Linux machines over SSH. One machine is the target: it runs this web UI and is reachable from the internet. Every other machine is a client: it keeps an SSH connection open to the target, so it needs no open ports and can sit behind NAT.

+

You decide what goes where by wiring nodes on the Patch page, like cables on a patch panel. A source is a service you want to reach (for example a web server on a client), a sink 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.

+
+ +
+

Getting started

+
    +
  1. Install a client with ./install.sh --role client and set TargetHost (and TargetPort) in /etc/patchbay/patchbay.conf.
  2. +
  3. On the client, run patchbayd --pubkey and paste the output under Settings → Clients.
  4. +
  5. Start patchbayd on the client. It shows as online on the Patch and Services pages within a few seconds.
  6. +
  7. On the Patch 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".
  8. +
+
+ +
+

Patch editor

+
    +
  • Add creates a node in the middle of the view. Fill in its fields; changes are saved automatically and applied straight away.
  • +
  • Wiring: 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 × button, or double-click it, to remove it.
  • +
  • Wire colours: blue is TCP, orange is UDP. A dashed wire means source and sink use different protocols and will not work.
  • +
  • Moving around: drag empty space to pan, scroll to zoom, Fit shows everything.
  • +
  • Selecting: 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.
  • +
  • Tidy snaps all nodes to the grid and keeps them on it while it is switched on.
  • +
  • Each sink shows its state at the bottom: live traffic and open connections, or what is wrong (see Status messages).
  • +
+
+ +
+

Node types

+ + + + + + + + + +
Client SourceA service on a client, e.g. 127.0.0.1:80 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.
Public SinkA port opened on the target. Bind 0.0.0.0 makes it reachable from everywhere, 127.0.0.1 only from the target itself.
Client SinkA port opened on a client. Use it to reach a service on one client from another client, without exposing it publicly.
SplitterSends 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.
Tunnel SourceA host behind a VPN interface (tun0 etc.), on the target or on a client. Enter the interface and the peer's address inside the VPN.
Tunnel SinkA port that only accepts connections arriving through a VPN interface, so VPN peers can reach a service that is not open anywhere else.
+

Label is a free-text name shown in Stats. Interface suggestions come from the tun interfaces each host reports.

+
+ +
+

Visitor addresses

+

Normally a service behind PatchBay sees every visitor as coming from PatchBay itself (for example 127.0.0.1). Every sink has two optional checkboxes to pass the real address on. Only one can be on at a time.

+
    +
  • PROXY v2 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 mod_remoteip and RemoteIPProxyProtocol On, nginx with listen ... proxy_protocol, HAProxy, Postfix, Dovecot. For UDP the header is in front of every datagram.
  • +
  • Transparent source spoofing 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 127.0.0.1. PatchBay sets up the routing this needs on the client by itself (setting TransparentTable, default 470). If the visitor uses IPv6 and the service only IPv4, that connection falls back to the normal address.
  • +
+
+ +
+

Services and Stats

+

Services 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.

+

Stats 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.

+
+ +
+

Settings and login

+
    +
  • Logging in takes your password plus a 6-digit code sent by email. Too many failed attempts block your address for a while.
  • +
  • All users can edit the patch and add or remove clients. The sysop (the account in patchbay.conf) also manages users, the mail server and statistics retention.
  • +
  • Removing a client disconnects it at once; its nodes stay in the patch without a client until you pick another one.
  • +
+
+ +
+

Status messages

+ + + + + + + + + + +
not connectedThe sink has no wire into its input.
source offlineThe client with the source is not connected right now. Check that patchbayd runs there and its key is listed under Settings.
protocol mismatchSource and sink use different protocols (TCP vs UDP).
bind ... failedThe port is already used by another program, or the bind address does not exist on that host.
interface not presentThe tun interface does not exist (yet). PatchBay retries every few seconds, so this clears once the VPN is up.
transparent spoofing needs a source on a clientSpoofing only works when the service is reached from a client. Use PROXY v2 instead, or move the source.
daemon not reachableThe web UI cannot talk to patchbayd on the target. Changes are saved and applied once it runs again.
+
+{% endblock %} diff --git a/web/patchbay_web/templates/patch.html b/web/patchbay_web/templates/patch.html index 85a2565..f165609 100644 --- a/web/patchbay_web/templates/patch.html +++ b/web/patchbay_web/templates/patch.html @@ -15,6 +15,7 @@ + {% endblock %} @@ -22,7 +23,7 @@
-
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.
+
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.
{% endblock %} {% block scripts %} diff --git a/web/patchbay_web/validate.py b/web/patchbay_web/validate.py index b36fec1..d96fb02 100644 --- a/web/patchbay_web/validate.py +++ b/web/patchbay_web/validate.py @@ -13,6 +13,11 @@ NODE_TYPES = {"client_source", "client_sink", "public_sink", "splitter", "tunnel SOURCES = {"client_source", "tunnel_source"} HAS_OUTPUT = SOURCES | {"splitter"} 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}$") HOST_RE = re.compile(r"^[A-Za-z0-9.:_\-\[\]%]{0,255}$") 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"): raise Invalid("protocol must be tcp or udp") 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({ "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)), }) diff --git a/web/patchbay_web/views.py b/web/patchbay_web/views.py index a65bd61..f63d6b5 100644 --- a/web/patchbay_web/views.py +++ b/web/patchbay_web/views.py @@ -24,6 +24,12 @@ def patch(): return render_template("patch.html") +@bp.get("/help") +@login_required +def help_page(): + return render_template("help.html") + + @bp.get("/services") @login_required def services(): diff --git a/web/tests/test_auth.py b/web/tests/test_auth.py index da65ea9..8b47094 100644 --- a/web/tests/test_auth.py +++ b/web/tests/test_auth.py @@ -43,6 +43,13 @@ class AuthTest(AppCase): self.assertEqual(self.client.get("/settings").status_code, 302) 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): self.login() tok = self.token() diff --git a/web/tests/test_graph.py b/web/tests/test_graph.py index 7edc480..27d6bb3 100644 --- a/web/tests/test_graph.py +++ b/web/tests/test_graph.py @@ -48,6 +48,16 @@ class ValidateTest(unittest.TestCase): n, _ = validate.graph({"nodes": [{"id": -3, "type": "public_sink", "port": 1, "iface": "x"}], "links": []}, {1}) 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): def test_roundtrip_keeps_ids(self): @@ -61,7 +71,8 @@ class GraphApiTest(AppCase): "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}, + {"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}], } @@ -73,6 +84,7 @@ class GraphApiTest(AppCase): g = self.client.get("/api/graph").get_json() self.assertEqual(len(g["nodes"]), 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). for n in payload["nodes"]: n["id"] = ids[str(n["id"])]