Repository navigation
Linux 4.0.0 - #269
Merged
Merged
Linux 4.0.0#269
Conversation
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.
…rs, permissions and app
…(once per destination vs every connection)
… 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.
…for the documentation images
…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).
| struct stat st; | ||
| if (stat(dir, &st) == -1) { | ||
| if (mkdir(dir, 0700) != 0 && errno != EEXIST) return false; | ||
| } else if (chmod(dir, 0700) != 0) { |
| 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; |
| stall.close() | ||
|
|
||
| # a batch left open | ||
| w.ok("rule.add", "curl", "192.0.2.0/24", "*", "0", "0", "0") if False else None |
| " 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) |
| 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) |
| 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()) |
| 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 |
| 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]) |
| 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:])) |
|
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.







Linked issue
Fixes #
What does this pull request do?
Tests
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.