Skip to content

Linux 4.0.0 - #269

Merged
Anof-cyber merged 30 commits into
masterfrom
dev
Oct 21, 2026
Merged

Anof-cyber merged 30 commits into
masterfrom
dev

Conversation

@Anof-cyber

Copy link
Copy Markdown
Member

Linked issue

Fixes #

What does this pull request do?

Tests

  • Did you run the tests locally? (which ones, on which system):
  • Result or output (paste the last lines):
  • Did anything fail? (what, and why):
  • New tests added (or "none"):
  • Tests removed or changed (or "none"):

Anything else? (optional)


By opening this pull request I confirm that it targets dev, that I commented on the linked issue before starting, and that I ran the tests listed above.

Anof-cyber and others added 30 commits October 20, 2026 17:11
Boot service: absolute profile path, root-owned CLI path only, systemd hardening; setup.sh --uninstall.
Relay: UDP datagrams must come from the process that owns the flow, per-process session cap, flow addresses per
process, probe exhaustion refuses instead of borrowing, SOCKS5 UDP relay address may not point at this host,
credentials wiped from worker stacks, rate-limited unknown-client log, teardown under the data mutex.
Core: slow-path decisions prefer the kernel's executable identity over /proc; control characters sanitised in callbacks.
DNS tap: only records in the question's bailiwick are learned.
Profiles: control characters, non-integral numbers, duplicate keys and Id >= 2^31 rejected; linear glob for log
filters; strict --profile name resolution; double close fixed; update feed version validated.
CLI/GUI: curl --proto/--; GUI update check uses the strict parser and opens project pages only; exports handed to
the invoking user. Release workflow publishes .sha256. README security model and SECURITY_AUDIT.md document the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…best effort)

Runs the real engine and WinDivert driver through ProxyBridge_CLI.exe with a real SOCKS5 proxy (Nexus Proxy, pinned),
a small HTTP proxy and real requests to real servers: SOCKS5, HTTP proxy, authentication, Direct, Block, domain and
CIDR rules, UDP DNS via UDP ASSOCIATE, an HTTPS download with a checksum and the block-IPv6 recipe. Each check confirms
from the proxy's own log that the traffic really went through it.

It loads a kernel driver, so the script refuses to run outside GitHub Actions unless -IKnowThisLoadsTheKernelDriver is
given. The workflow step is best effort (continue-on-error) and uploads its logs.
…real DNS answer

First run on a hosted runner: 17 of 18 checks passed with the real driver. The UDP one failed because stock Nexus answers
'general failure' to UDP ASSOCIATE under current Node.js (IPv6 relay socket bound to 0.0.0.0), so ProxyBridge dropped the
datagrams; nslookup still printed its 'Address' header, which the check wrongly accepted. The test proxy copy now gets
the same three-line fix as Linux/tests/nexus-linux-udp.patch (each edit must match exactly once), and the UDP check
requires an answer section.
…print

The datagram check looked for the association line, which Nexus prints once at start-up, so it never grew; it now counts the
datagram line for the queried destination. The DNS answer check no longer relies on nslookup's 'Name:' label (it prints
'(null)' on a runner) and only accepts an address listed after the queried name.
…d load with real traffic

More scenarios on the same engine run: wildcard / several / case-insensitive / full-path / quoted-path programs, port and port-range
rules, IPv4 wildcard and range, a TCP-only rule leaving UDP alone, IPv6 CIDR (when the runner has IPv6), rule order, a disabled
rule, a proxy that is not running, a proxy killed in the middle of a transfer, 30 parallel HTTPS downloads with checksums and a
25 MB transfer.
… for the GUI

libelf and zlib are built from pinned, checksum-verified sources (with libbpf) as static PIC libraries and linked into
libproxybridge.so; the command line program links the engine in and is one file that needs only glibc.
setup.sh/deploy.sh install nothing for the CLI and GTK3 only when the GUI is shipped and GTK3 is missing.
The audit and the portability CI step now require the CLI and library to run in a bare image.
… icon, UDP marked in the connection log

The rule dialog has a single Action list (Direct, Block, then each proxy) and the rule list shows 'Proxy: <name>' like the
Windows app. The global DNS via Proxy menu item is removed (DNS via proxy stays a per-proxy option). The window uses the
ProxyBridge logo as its icon. UDP flows are marked '(UDP)' in the GUI and CLI connection log. Open menus close when the window
loses focus.
…ss with SIGPIPE (#268)

splice() has no MSG_NOSIGNAL, so forwarding into a connection whose peer had reset raised SIGPIPE and ended the CLI or GUI
hosting the relay. Every relay thread now blocks SIGPIPE, the write fails with EPIPE and only that connection is closed; the
embedding application's own signal handling is untouched. A white-box test drives the real pump_splice() and thread entry
(it reproduces the crash without the fix); a relay-level test checks traffic keeps flowing after resets in both directions.
The GUI test scripts start the Broadway daemon without core dumps and stop the client first.
…logo for the GUI

A stale proxybridge.desktop from an older install pointed at an icon that was never installed, so the dock showed the generic
gear. The package now contains proxybridge.desktop (StartupWMClass=ProxyBridgeGUI) and the logo in two sizes; setup.sh and
deploy.sh install them into /usr/share and refresh the icon cache, and --uninstall removes them.
…etting up (heap corruption under load); raise the open file limit; UDP sender check no longer drops legitimate datagrams; full end-to-end suite

Found by the new tests/e2e_full.sh (engine off vs on, real profiles, Nexus proxies, HTTP/3):
- sess_new() evicted the oldest session of a process that went past its share even when a worker thread was still setting it
  up: use-after-free, 'malloc(): mismatching next->prev_size' and an aborted engine under 1000 concurrent UDP sockets. Sessions in
  setup are never evicted now; the table is 4096 sessions, 2048 per process. A relay test goes past the share (fails under ASan
  on the old code).
- The relay now raises its open-file limit (soft limit 1024 is the default, also for the systemd unit, which gets LimitNOFILE).
- The sender check read /proc while the owner changed its fd table (false drops under parallel sockets) and refused datagrams of
  programs that sent and exited. It re-reads a few times and accepts a vanished owner.
- tests: e2e_full.sh, full_client.py, full_quic_server.py, pbl.py --write; proc_test covers pb_proc_pid_owns_udp; fake_dns bigger
  buffers.
…ts follow the new CLI, UI and nexus

- pb_core_udp_sender_ok no longer walks /proc/<pid>/fd on the relay's single UDP thread (milliseconds per new flow for a process with
  many sockets, and false drops while the owner changes its descriptors). It reads the uid column of /proc/net/udp: a datagram is
  refused only when a socket bound to the source port belongs to another user than the flow's owner (root excepted); a closed socket
  or an exited sender cannot be told and is delivered (fire-and-forget senders were being blocked). Documented as best effort.
- e2e_full: fire-and-forget check with a counting origin, kernel UDP receive-buffer drops reported, the new Nexus without patching
  (--max-per-ip), rules with port ranges so the kernel can decide them, thresholds that tolerate 1% UDP loss under bursts.
- e2e_nexus (the original real-proxy suite) pins ?dns=local; e2e_profile expects 'Loading profile:'; rules_diff understands the
  '(UDP)' marker and keeps UDP sockets open per batch; gui_ui expects the absolute profile path in the boot unit.
- README: UDP slow-path limitation, the UDP sender check as it really is, Nexus 1.1.
…and every item works directly

New, Duplicate, Rename, Delete, Import, Export and Switch Profile (a submenu with the active profile checked) are items of a
top-level Profile menu and act on the active profile. The Proxy > Profiles... dialog with its list is gone. New, Duplicate and
Import make the new profile the active one, as in the Windows app; Delete activates another profile. UI test and README follow.
…ngs, Help) with Documentation

Settings holds what was crowding the Proxy menu: traffic logging, start at boot, auto-clear, log every connection, update check on
startup and the zoom commands (formerly the View menu). Help has Documentation (the Windows app's address), Check for Updates and
About. Proxy keeps Enable ProxyBridge, Proxy Settings, Proxy Rules, Log Filters and the Linux-only engine items and Exit. The UI test
checks the exact menu layout, the documentation address and that the update dialog opens a project release page.
…at talks to it over a socket

proxybridge --daemon owns the engine, the profile files and settings.json and listens on /run/proxybridge/ctl.sock. The
window no longer needs root: every engine call, the profile store, live connections, logs and the proxy test go over a
small length-prefixed protocol (src/pb_ipc.*), through a client layer (gui/pb_client.*) that falls back to the in-process
engine for a window started as root without a service.

Access: root and members of the group 'proxybridge' (group read at every connection, no re-login); anyone else is refused
and logged. The window refuses a socket not owned by root. Frames are bounded before allocation, 16 windows at most,
slow windows lose events instead of blocking the engine.

Start at Boot is now a setting of the service (settings.json StartAtBoot): on, the service applies the active profile at
boot and keeps routing after the window closes; off, it is idle until a window asks and closing the window stops routing.

ProxyBridge_BeginBatch/EndBatch load a whole configuration into the kernel datapath once, so switching profiles or
reconnecting a window never exposes a half-built rule set. ProxyBridge --print-unit prints the systemd unit.

Tests: ipc unit/fuzz (ASan), e2e_daemon (protocol, garbage, access control by group, store, engine, batch, boot),
e2e_service (the exact unit under real systemd: sandbox, eBPF, crash/restart), gui_service (the shipped GUI as an
unprivileged user), gui_ui now runs the whole window suite once embedded and once through a service.
… for the service

setup.sh creates the group 'proxybridge', adds the invoking user, installs the unit from 'ProxyBridge --print-unit', enables
and (re)starts it; --uninstall removes it, --purge also the group. The launcher entry is no longer hidden: the window needs
no sudo. The README describes the service, access control and Start at Boot, and lists the new tests; CI runs them.
…ain; test it with a service restart under an open window
Service (cli/daemon.c, src/pb_ipc.*):
- the per-window event budget never refilled, so 20000 events over a connection's life blinded the window (any local user could burn
  it with refused connections); the budget now counts what is waiting, and a window is told when events were dropped
- refused connections are cheap: no group lookup for a user refused a moment ago, one journal line per user per minute, nothing in
  the log that windows show (a flood used to fill the journal, the log ring and the listen queue)
- responses a window does not read are capped (64 MiB, then the window is cut off); a frame must arrive within 30 s of its header;
  the frame limit is 8 MiB
- a batch left open is applied after 10 s; engine start/stop are serialized; one proxy test at a time; its target host is validated
- store: profile names without control characters, active profile must exist, 256 MiB store limit, leftovers of interrupted writes swept
- a session ends within seconds when its user leaves the group; the explanation is delivered before the connection closes
- accept loop backs off on EMFILE; peer credentials are initialized

Engine (src/pb_relay.c): the two HEAD probes of the proxy test sent snprintf's wanted length, reading past a 600-byte stack buffer
for a long target host (reachable by a group member through proxy.test).

Unit (src/pb_unit.c): capability bounding set (7 capabilities), address families, namespaces, syscall ABI, MDWE, UMask, PrivateDevices,
ProtectKernelTunables/ControlGroups/Hostname, MemoryMax, TasksMax; systemd-analyze security 7.4 -> 4.4. Each directive was tried against
the running engine first.

Installer: deploy.sh now downloads into a private mktemp directory, verifies the SHA-256, refuses unsafe archive paths and runs the
archive's setup.sh (it used a predictable /tmp name, cp onto the running binary, chmod 755 on /etc/proxybridge and had no service
setup). setup.sh warns what group membership grants, uses '--', and can skip adding the user (PB_NO_GROUP_USER=1).

Window: connect() has a time limit (it runs on the main loop), requests use the descriptor they were made on, lchown in embedded mode.

Tests: e2e_daemon covers each of the above (101 checks); ipc_test covers the frame timeout; api_live the long host; gui_ui and
gui_headless no longer touch a service installed on the machine running them.
…tings when the window uses the service

The config.ini of those versions sits in /etc/proxybridge, which an unprivileged window cannot read, and only the root window
converted it: with the service the upgraded user would have seen an empty window. The window now asks the service for the old
file (store.legacy), converts it into a profile as before, and has the service set it aside (store.legacy_done).
Test: gui_service starts a service over a 3.2.x-format config.ini and checks the old rule routes and the file becomes config.ini.bak.
…r from a readable copy; e2e_exempt prints diagnostics on failure

Both suites started failing on GitHub's runners between 2026-10-20 and 2026-10-21 for the same commit (re-running yesterday's green run
fails identically), so the cause is the runner image, not the code. lifecycle: the build folder is not reachable by uid 65534 there.
exempt: the request is answered but the proxy never sees it; the output of ProxyBridge, the proxy log and the addresses are now printed
so the next run says why.
…its for the output to settle

exempt: GitHub's new runner image maps the machine's own address to its host name, so ProxyBridge (DNS via proxy) correctly sent
the proxy the name; the test looked for the address. logging: a fixed one-second wait failed 2 of 25 runs under CPU load with different
counts each time; it now waits until the output stops growing (0 of 25 afterwards).
Comment thread Linux/cli/daemon.c
struct stat st;
if (stat(dir, &st) == -1) {
if (mkdir(dir, 0700) != 0 && errno != EEXIST) return false;
} else if (chmod(dir, 0700) != 0) {
Comment thread Linux/cli/daemon.c
char path[4400];
snprintf(path, sizeof path, "%s/config.ini", pbp_config_dir());
struct stat lst;
FILE *lf = lstat(path, &lst) == 0 && S_ISREG(lst.st_mode) && lst.st_size <= (1 << 20) ? fopen(path, "rb") : NULL;
Comment thread Linux/tests/e2e_daemon.py
stall.close()

# a batch left open
w.ok("rule.add", "curl", "192.0.2.0/24", "*", "0", "0", "0") if False else None
Comment thread Linux/tests/e2e_daemon.py
" except Exception: time.sleep(0.001)\nprint(n)\n" % SOCK)
w = conn()
before = journal_lines()
r = subprocess.run([sys.executable, "-c", fl_code], capture_output=True, text=True)
check("--print-unit gives a unit that starts the daemon", "ExecStart=\"%s/ProxyBridge\" --daemon" % INSTALL in unit and "[Install]" in unit)
# the test's own configuration folder instead of /etc/proxybridge; every other line is the installer's
unit = unit.replace("ReadWritePaths=/etc/proxybridge", "ReadWritePaths=" + CFG).replace("[Service]\n", "[Service]\nEnvironment=PROXYBRIDGE_CONFIG_DIR=%s\n" % CFG)
open(UNIT_PATH, "w").write(unit)
out = subprocess.run(["systemctl", "show", UNIT, "-p", "MainPID", "--value"], capture_output=True, text=True).stdout.strip()
mainpid = int(out)
check("it runs as root", os.stat("/proc/%d" % mainpid).st_uid == 0)
nofile = [l for l in open("/proc/%d/limits" % mainpid) if l.startswith("Max open files")][0].split()
check("it runs as root", os.stat("/proc/%d" % mainpid).st_uid == 0)
nofile = [l for l in open("/proc/%d/limits" % mainpid) if l.startswith("Max open files")][0].split()
check("the raised open-file limit applies (LimitNOFILE)", int(nofile[3]) >= 1048576, nofile)
status = dict(l.split(":\t") for l in open("/proc/%d/status" % mainpid).read().splitlines() if ":\t" in l)
Comment thread Linux/tests/e2e_daemon.py
def start_daemon(extra=()):
env = dict(os.environ, PROXYBRIDGE_CONFIG_DIR=CFG)
p = subprocess.Popen([PB, "--daemon", "--socket", SOCK, "--verbose", "1"] + list(extra), env=env,
stdout=open(os.path.join(WORK, "daemon.out"), "ab"), stderr=subprocess.STDOUT)
Comment thread Linux/tests/e2e_daemon.py
if p.poll() is not None:
break
time.sleep(0.1)
raise RuntimeError("daemon did not start: " + open(os.path.join(WORK, "daemon.out")).read())
Comment thread Linux/tests/e2e_daemon.py
d1 = start_daemon()
dpid = d1.pid
def rss_mb():
return int([l for l in open("/proc/%d/status" % dpid) if l.startswith("VmRSS")][0].split()[1]) // 1024
Comment thread Linux/tests/e2e_daemon.py
def rss_mb():
return int([l for l in open("/proc/%d/status" % dpid) if l.startswith("VmRSS")][0].split()[1]) // 1024
def journal_lines():
return len([l for l in open(os.path.join(WORK, "daemon.out"), errors="replace") if "refused a connection" in l])
Comment thread Linux/tests/e2e_daemon.py
d1 = start_daemon()
time.sleep(1)
rc, out = curl("http://192.0.2.7/")
check("with no window open, the active profile is already routing", "hello-from-A dst=192.0.2.7:80" in out, (rc, out, open(os.path.join(WORK, "daemon.out")).read()[-600:]))
@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
0.0% Coverage on New Code (required ≥ 80%)
C Reliability Rating on New Code (required ≥ A)
D Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@Anof-cyber
Anof-cyber merged commit 416a07f into master Oct 21, 2026
18 of 20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants