wg-bridge 1.0.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.3
2
+ Name: wg-bridge
3
+ Version: 1.0.3
4
+ Summary: Tunnel WireGuard UDP traffic over a TCP/SSH connection for networks that block UDP
5
+ Author: Wei Liu
6
+ Author-email: Wei Liu <liuwei_berkeley@berkeley.edu>
7
+ Requires-Dist: typer>=0.12.0
8
+ Requires-Python: >=3.14
9
+ Description-Content-Type: text/markdown
10
+
11
+ # wg-bridge
12
+
13
+ Tunnel WireGuard's UDP traffic over a TCP connection (an SSH tunnel), for networks that block or throttle UDP but allow TCP/SSH.
14
+
15
+ ## How it works
16
+
17
+ ```
18
+ WireGuard client -> UDP -> wg-bridge --client -> TCP -> SSH tunnel -> TCP -> wg-bridge --server -> UDP -> WireGuard server
19
+ ```
20
+
21
+ - **Client** side runs next to your local WireGuard client. It binds the UDP port your WireGuard client talks to, and forwards everything over a TCP connection into an SSH tunnel.
22
+ - **Server** side runs near the actual WireGuard server. It listens for the TCP connection coming out of the SSH tunnel and forwards packets to the real WireGuard UDP endpoint.
23
+
24
+ You are responsible for setting up the SSH tunnel itself (e.g. `ssh -L <tcp-port>:localhost:<tcp-port> user@server` or a remote forward, depending on your topology) — `wg-bridge` only handles the UDP↔TCP relay on each end.
25
+
26
+ Each UDP datagram is sent over the TCP stream with a 2-byte length prefix, so datagram boundaries survive the byte-stream relay even when several packets arrive back-to-back (bursty traffic like a VNC/RDP session, not just isolated pings). **This is a wire-format change — the client and server sides must run the same wg-bridge version; an old client cannot talk to a new server or vice versa.**
27
+
28
+ ## Install
29
+
30
+ ### From PyPI
31
+
32
+ ```bash
33
+ pip install wg-bridge
34
+ ```
35
+
36
+ Or, to install it as an isolated global command-line tool rather than into a project environment (recommended for a CLI-only package like this):
37
+
38
+ ```bash
39
+ pipx install wg-bridge
40
+ # or, using uv:
41
+ uv tool install wg-bridge
42
+ ```
43
+
44
+ ### From GitHub (without cloning)
45
+
46
+ Installs the latest code on the default branch directly from the repository:
47
+
48
+ ```bash
49
+ pip install git+https://github.com/liuweiseu/wg-bridge.git
50
+ # or:
51
+ pipx install git+https://github.com/liuweiseu/wg-bridge.git
52
+ # or:
53
+ uv tool install git+https://github.com/liuweiseu/wg-bridge.git
54
+ ```
55
+
56
+ Pin a specific released version by appending `@vX.Y.Z` (see the [releases page](https://github.com/liuweiseu/wg-bridge/releases)), e.g. `git+https://github.com/liuweiseu/wg-bridge.git@v1.0.3`.
57
+
58
+ ### From source
59
+
60
+ ```bash
61
+ git clone https://github.com/liuweiseu/wg-bridge.git
62
+ cd wg-bridge
63
+ uv sync
64
+ ```
65
+
66
+ Every `wg-bridge ...` command shown below becomes `uv run wg-bridge ...` when run this way (unless you've activated the project's `.venv` yourself).
67
+
68
+ ### Shell tab-completion
69
+
70
+ Once installed by any of the methods above, enable shell tab-completion (bash/zsh/fish/PowerShell) by running once from an interactive shell and restarting your terminal:
71
+
72
+ ```bash
73
+ wg-bridge --install-completion
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ By default `--server` and `--client` detach into the background and hand control of the terminal straight back to you — no `&` or `nohup` needed. Output goes through Python's `logging` module to a `wg-bridge.log` file (the first line logged is always the working mode); use `--status` to check on it and `--stop` to stop it.
79
+
80
+ On the machine near the WireGuard server:
81
+
82
+ ```bash
83
+ wg-bridge --server --wg-host <wireguard-server-ip> --tcp-port <port> [--wg-port 51820] [--log-file <path>]
84
+ ```
85
+
86
+ ```
87
+ $ wg-bridge --server --wg-host 192.168.88.199 --tcp-port 5980
88
+ [INFO] wg-bridge server started in background (PID 39195).
89
+ [INFO] listening on TCP 0.0.0.0:5980 -> forwarding to UDP 192.168.88.199:51820
90
+ [INFO] logs: /path/to/cwd/wg-bridge.log
91
+ ```
92
+
93
+ ```
94
+ $ cat wg-bridge.log
95
+ 2026-09-09 23:18:47 [INFO] Working mode: server
96
+ 2026-09-09 23:18:47 [INFO] Server bridge started: TCP:5980 -> UDP 192.168.88.199:51820
97
+ ```
98
+
99
+ `--log-file` sets the path (directory + filename) of the log file; it defaults to `./wg-bridge.log` in the current directory (its parent directory is created if it doesn't exist yet).
100
+
101
+ On the machine running the local WireGuard client:
102
+
103
+ ```bash
104
+ wg-bridge --client --tcp-port <port> [--wg-port 51820] [--log-file <path>]
105
+ ```
106
+
107
+ Add `--foreground` to either command to run it attached to the terminal instead (useful for debugging) — `Ctrl-C` stops it. In foreground mode, log lines are also echoed to the console in addition to the log file.
108
+
109
+ To stop whichever bridge process is currently running on that machine:
110
+
111
+ ```bash
112
+ wg-bridge --stop
113
+ ```
114
+
115
+ To check whether a bridge process is currently running on that machine:
116
+
117
+ ```bash
118
+ wg-bridge --status
119
+ ```
120
+
121
+ ```
122
+ $ wg-bridge --status
123
+ [INFO] wg-bridge is running (PID 39195, mode=server)
124
+ [INFO] listening on TCP 0.0.0.0:5980 -> forwarding to UDP 192.168.88.199:51820
125
+ [INFO] logs: /path/to/cwd/wg-bridge.log
126
+ ```
127
+
128
+ | Flag | Mode | Default | Description |
129
+ |---|---|---|---|
130
+ | `--server` | — | — | Run in server mode (near the WireGuard server). |
131
+ | `--client` | — | — | Run in client mode (next to the local WireGuard client). |
132
+ | `--stop` | — | — | Stop the wg-bridge process currently running on this machine. |
133
+ | `--status` | — | — | Show whether wg-bridge is running on this machine, and its bound host/port info if so. |
134
+ | `--foreground` | server/client only | — | Run attached to the terminal instead of detaching into the background. |
135
+ | `--log-file` | server/client only | `./wg-bridge.log` | Path (directory + filename) of the log file. |
136
+ | `--version` | — | — | Print the wg-bridge version and exit. |
137
+ | `--help` / `-h` | — | — | Show this help and exit. |
138
+ | `--install-completion` | — | — | Install shell tab-completion for the detected shell. |
139
+ | `--show-completion` | — | — | Print the completion script for the detected shell, without installing it. |
140
+ | `--wg-host` | server only | `localhost` | WireGuard server address. |
141
+ | `--wg-port` | both | `51820` | WireGuard UDP port. |
142
+ | `--tcp-port` | server/client only | *(required)* | TCP tunnel port; must match the SSH tunnel endpoint and be the same value on both sides. |
143
+
144
+ Exactly one of `--server`, `--client`, `--stop`, or `--status` is required.
145
+
146
+ ## Notes
147
+
148
+ - Each side assumes a single active WireGuard peer/session at a time; it does not multiplex multiple peers through one bridge process.
149
+ - Both sides retry automatically if the TCP connection drops.
150
+ - Starting `--server` or `--client` records the running process's PID, its host/port settings, and its log file path in a state file under the system temp directory; starting a second instance while one is already tracked as running is refused until you `--stop` it. `--stop` sends the tracked process a termination signal and clears the state file; `--status` reports it (or reports not running, cleaning up the state file first if it points at a process that's already gone — e.g. after a crash).
151
+ - In the default background mode, if the process fails immediately (e.g. the TCP port is already in use), the command reports the failure and prints the last lines of `wg-bridge.log` instead of claiming success.
152
+ - `wg-bridge.log` is appended to, not truncated, across restarts — each run's own first line is always `Working mode: server`/`Working mode: client`.
@@ -0,0 +1,142 @@
1
+ # wg-bridge
2
+
3
+ Tunnel WireGuard's UDP traffic over a TCP connection (an SSH tunnel), for networks that block or throttle UDP but allow TCP/SSH.
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ WireGuard client -> UDP -> wg-bridge --client -> TCP -> SSH tunnel -> TCP -> wg-bridge --server -> UDP -> WireGuard server
9
+ ```
10
+
11
+ - **Client** side runs next to your local WireGuard client. It binds the UDP port your WireGuard client talks to, and forwards everything over a TCP connection into an SSH tunnel.
12
+ - **Server** side runs near the actual WireGuard server. It listens for the TCP connection coming out of the SSH tunnel and forwards packets to the real WireGuard UDP endpoint.
13
+
14
+ You are responsible for setting up the SSH tunnel itself (e.g. `ssh -L <tcp-port>:localhost:<tcp-port> user@server` or a remote forward, depending on your topology) — `wg-bridge` only handles the UDP↔TCP relay on each end.
15
+
16
+ Each UDP datagram is sent over the TCP stream with a 2-byte length prefix, so datagram boundaries survive the byte-stream relay even when several packets arrive back-to-back (bursty traffic like a VNC/RDP session, not just isolated pings). **This is a wire-format change — the client and server sides must run the same wg-bridge version; an old client cannot talk to a new server or vice versa.**
17
+
18
+ ## Install
19
+
20
+ ### From PyPI
21
+
22
+ ```bash
23
+ pip install wg-bridge
24
+ ```
25
+
26
+ Or, to install it as an isolated global command-line tool rather than into a project environment (recommended for a CLI-only package like this):
27
+
28
+ ```bash
29
+ pipx install wg-bridge
30
+ # or, using uv:
31
+ uv tool install wg-bridge
32
+ ```
33
+
34
+ ### From GitHub (without cloning)
35
+
36
+ Installs the latest code on the default branch directly from the repository:
37
+
38
+ ```bash
39
+ pip install git+https://github.com/liuweiseu/wg-bridge.git
40
+ # or:
41
+ pipx install git+https://github.com/liuweiseu/wg-bridge.git
42
+ # or:
43
+ uv tool install git+https://github.com/liuweiseu/wg-bridge.git
44
+ ```
45
+
46
+ Pin a specific released version by appending `@vX.Y.Z` (see the [releases page](https://github.com/liuweiseu/wg-bridge/releases)), e.g. `git+https://github.com/liuweiseu/wg-bridge.git@v1.0.3`.
47
+
48
+ ### From source
49
+
50
+ ```bash
51
+ git clone https://github.com/liuweiseu/wg-bridge.git
52
+ cd wg-bridge
53
+ uv sync
54
+ ```
55
+
56
+ Every `wg-bridge ...` command shown below becomes `uv run wg-bridge ...` when run this way (unless you've activated the project's `.venv` yourself).
57
+
58
+ ### Shell tab-completion
59
+
60
+ Once installed by any of the methods above, enable shell tab-completion (bash/zsh/fish/PowerShell) by running once from an interactive shell and restarting your terminal:
61
+
62
+ ```bash
63
+ wg-bridge --install-completion
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ By default `--server` and `--client` detach into the background and hand control of the terminal straight back to you — no `&` or `nohup` needed. Output goes through Python's `logging` module to a `wg-bridge.log` file (the first line logged is always the working mode); use `--status` to check on it and `--stop` to stop it.
69
+
70
+ On the machine near the WireGuard server:
71
+
72
+ ```bash
73
+ wg-bridge --server --wg-host <wireguard-server-ip> --tcp-port <port> [--wg-port 51820] [--log-file <path>]
74
+ ```
75
+
76
+ ```
77
+ $ wg-bridge --server --wg-host 192.168.88.199 --tcp-port 5980
78
+ [INFO] wg-bridge server started in background (PID 39195).
79
+ [INFO] listening on TCP 0.0.0.0:5980 -> forwarding to UDP 192.168.88.199:51820
80
+ [INFO] logs: /path/to/cwd/wg-bridge.log
81
+ ```
82
+
83
+ ```
84
+ $ cat wg-bridge.log
85
+ 2026-09-09 23:18:47 [INFO] Working mode: server
86
+ 2026-09-09 23:18:47 [INFO] Server bridge started: TCP:5980 -> UDP 192.168.88.199:51820
87
+ ```
88
+
89
+ `--log-file` sets the path (directory + filename) of the log file; it defaults to `./wg-bridge.log` in the current directory (its parent directory is created if it doesn't exist yet).
90
+
91
+ On the machine running the local WireGuard client:
92
+
93
+ ```bash
94
+ wg-bridge --client --tcp-port <port> [--wg-port 51820] [--log-file <path>]
95
+ ```
96
+
97
+ Add `--foreground` to either command to run it attached to the terminal instead (useful for debugging) — `Ctrl-C` stops it. In foreground mode, log lines are also echoed to the console in addition to the log file.
98
+
99
+ To stop whichever bridge process is currently running on that machine:
100
+
101
+ ```bash
102
+ wg-bridge --stop
103
+ ```
104
+
105
+ To check whether a bridge process is currently running on that machine:
106
+
107
+ ```bash
108
+ wg-bridge --status
109
+ ```
110
+
111
+ ```
112
+ $ wg-bridge --status
113
+ [INFO] wg-bridge is running (PID 39195, mode=server)
114
+ [INFO] listening on TCP 0.0.0.0:5980 -> forwarding to UDP 192.168.88.199:51820
115
+ [INFO] logs: /path/to/cwd/wg-bridge.log
116
+ ```
117
+
118
+ | Flag | Mode | Default | Description |
119
+ |---|---|---|---|
120
+ | `--server` | — | — | Run in server mode (near the WireGuard server). |
121
+ | `--client` | — | — | Run in client mode (next to the local WireGuard client). |
122
+ | `--stop` | — | — | Stop the wg-bridge process currently running on this machine. |
123
+ | `--status` | — | — | Show whether wg-bridge is running on this machine, and its bound host/port info if so. |
124
+ | `--foreground` | server/client only | — | Run attached to the terminal instead of detaching into the background. |
125
+ | `--log-file` | server/client only | `./wg-bridge.log` | Path (directory + filename) of the log file. |
126
+ | `--version` | — | — | Print the wg-bridge version and exit. |
127
+ | `--help` / `-h` | — | — | Show this help and exit. |
128
+ | `--install-completion` | — | — | Install shell tab-completion for the detected shell. |
129
+ | `--show-completion` | — | — | Print the completion script for the detected shell, without installing it. |
130
+ | `--wg-host` | server only | `localhost` | WireGuard server address. |
131
+ | `--wg-port` | both | `51820` | WireGuard UDP port. |
132
+ | `--tcp-port` | server/client only | *(required)* | TCP tunnel port; must match the SSH tunnel endpoint and be the same value on both sides. |
133
+
134
+ Exactly one of `--server`, `--client`, `--stop`, or `--status` is required.
135
+
136
+ ## Notes
137
+
138
+ - Each side assumes a single active WireGuard peer/session at a time; it does not multiplex multiple peers through one bridge process.
139
+ - Both sides retry automatically if the TCP connection drops.
140
+ - Starting `--server` or `--client` records the running process's PID, its host/port settings, and its log file path in a state file under the system temp directory; starting a second instance while one is already tracked as running is refused until you `--stop` it. `--stop` sends the tracked process a termination signal and clears the state file; `--status` reports it (or reports not running, cleaning up the state file first if it points at a process that's already gone — e.g. after a crash).
141
+ - In the default background mode, if the process fails immediately (e.g. the TCP port is already in use), the command reports the failure and prints the last lines of `wg-bridge.log` instead of claiming success.
142
+ - `wg-bridge.log` is appended to, not truncated, across restarts — each run's own first line is always `Working mode: server`/`Working mode: client`.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "wg-bridge"
3
+ version = "1.0.3"
4
+ description = "Tunnel WireGuard UDP traffic over a TCP/SSH connection for networks that block UDP"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = ["typer>=0.12.0"]
8
+
9
+ [[project.authors]]
10
+ name = "Wei Liu"
11
+ email = "liuwei_berkeley@berkeley.edu"
12
+
13
+ [project.scripts]
14
+ wg-bridge = "wg_bridge:main"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.7,<0.13.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "wg-bridge"
3
+ version = "1.0.3"
4
+ description = "Tunnel WireGuard UDP traffic over a TCP/SSH connection for networks that block UDP"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Wei Liu", email = "liuwei_berkeley@berkeley.edu" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = ["typer>=0.12.0"]
11
+
12
+ [project.scripts]
13
+ wg-bridge = "wg_bridge:main"
14
+
15
+ [build-system]
16
+ requires = ["uv_build>=0.12.7,<0.13.0"]
17
+ build-backend = "uv_build"
@@ -0,0 +1,5 @@
1
+ from wg_bridge.cli import app
2
+
3
+
4
+ def main() -> None:
5
+ app()
@@ -0,0 +1,4 @@
1
+ from wg_bridge import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import logging
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from importlib.metadata import version as _pkg_version
9
+ from pathlib import Path
10
+
11
+ import typer
12
+
13
+ from wg_bridge.client import run_client
14
+ from wg_bridge.logging_setup import configure_cli_logging, configure_worker_logging
15
+ from wg_bridge.server import run_server
16
+ from wg_bridge.state import (
17
+ describe_binding,
18
+ print_status,
19
+ process_running,
20
+ read_state,
21
+ remove_state,
22
+ stop_running,
23
+ write_state,
24
+ )
25
+
26
+ app = typer.Typer(
27
+ add_completion=True,
28
+ no_args_is_help=True,
29
+ context_settings={"help_option_names": ["-h", "--help"]},
30
+ )
31
+
32
+
33
+ def _version_callback(value: bool) -> None:
34
+ if value:
35
+ typer.echo(f"wg-bridge {_pkg_version('wg-bridge')}")
36
+ raise typer.Exit()
37
+
38
+
39
+ def _run_in_foreground(mode: str, wg_host: str, wg_port: int, tcp_port: int, log_file: Path) -> None:
40
+ configure_worker_logging(log_file, mode=mode, console=True)
41
+ write_state(
42
+ mode=mode,
43
+ wg_host=wg_host if mode == "server" else None,
44
+ wg_port=wg_port,
45
+ tcp_port=tcp_port,
46
+ log_path=log_file,
47
+ )
48
+ atexit.register(remove_state)
49
+
50
+ if mode == "server":
51
+ run_server(wg_host=wg_host, wg_port=wg_port, tcp_port=tcp_port)
52
+ else:
53
+ run_client(wg_port=wg_port, tcp_port=tcp_port)
54
+
55
+
56
+ def _start_in_background(mode: str, wg_host: str, wg_port: int, tcp_port: int, log_file: Path) -> None:
57
+ child_args = [
58
+ f"--{mode}",
59
+ "--foreground",
60
+ "--wg-port",
61
+ str(wg_port),
62
+ "--tcp-port",
63
+ str(tcp_port),
64
+ "--log-file",
65
+ str(log_file),
66
+ ]
67
+ if mode == "server":
68
+ child_args += ["--wg-host", wg_host]
69
+
70
+ proc = subprocess.Popen(
71
+ [sys.executable, "-m", "wg_bridge", *child_args],
72
+ stdin=subprocess.DEVNULL,
73
+ stdout=subprocess.DEVNULL,
74
+ stderr=subprocess.DEVNULL,
75
+ start_new_session=True,
76
+ )
77
+
78
+ time.sleep(0.3) # give the child a moment to fail fast (e.g. port already in use)
79
+ if proc.poll() is not None: # poll() reaps the child if it already exited, unlike a raw kill(pid, 0) liveness check
80
+ logging.error(
81
+ "wg-bridge %s failed to start (exit code %s). Last log lines (%s):", mode, proc.returncode, log_file
82
+ )
83
+ if log_file.exists():
84
+ for line in log_file.read_text().splitlines()[-10:]:
85
+ logging.error("%s", line)
86
+ raise typer.Exit(1)
87
+
88
+ logging.info("wg-bridge %s started in background (PID %d).", mode, proc.pid)
89
+ logging.info("%s", describe_binding(mode, wg_host, wg_port, tcp_port))
90
+ logging.info("logs: %s", log_file)
91
+
92
+
93
+ @app.callback(invoke_without_command=True)
94
+ def main(
95
+ version: bool = typer.Option(
96
+ False,
97
+ "--version",
98
+ callback=_version_callback,
99
+ is_eager=True,
100
+ help="Show the wg-bridge version and exit.",
101
+ ),
102
+ server: bool = typer.Option(
103
+ False, "--server", help="Run as the server-side bridge, near the WireGuard server."
104
+ ),
105
+ client: bool = typer.Option(
106
+ False, "--client", help="Run as the client-side bridge, next to the local WireGuard client."
107
+ ),
108
+ stop: bool = typer.Option(
109
+ False, "--stop", help="Stop the wg-bridge process currently running on this machine."
110
+ ),
111
+ status: bool = typer.Option(
112
+ False, "--status", help="Show whether wg-bridge is currently running on this machine."
113
+ ),
114
+ foreground: bool = typer.Option(
115
+ False,
116
+ "--foreground",
117
+ help="Run in the foreground instead of detaching into the background (useful for debugging).",
118
+ ),
119
+ wg_host: str = typer.Option(
120
+ "localhost", "--wg-host", help="WireGuard server address (server mode only)."
121
+ ),
122
+ wg_port: int = typer.Option(
123
+ 51820, "--wg-port", help="WireGuard UDP port."
124
+ ),
125
+ tcp_port: int | None = typer.Option(
126
+ None, "--tcp-port", help="TCP tunnel port used to carry the bridged traffic (required for --server/--client)."
127
+ ),
128
+ log_file: Path = typer.Option(
129
+ Path("./wg-bridge.log"),
130
+ "--log-file",
131
+ help="Path (directory + filename) of the log file (server/client mode only). Defaults to ./wg-bridge.log.",
132
+ ),
133
+ ) -> None:
134
+ configure_cli_logging()
135
+
136
+ if sum([server, client, stop, status]) != 1:
137
+ raise typer.BadParameter("Specify exactly one of --server, --client, --stop, or --status.")
138
+
139
+ if stop:
140
+ stop_running()
141
+ return
142
+
143
+ if status:
144
+ print_status()
145
+ return
146
+
147
+ if tcp_port is None:
148
+ raise typer.BadParameter("--tcp-port is required for --server and --client mode.")
149
+
150
+ existing_state = read_state()
151
+ if existing_state is not None and process_running(existing_state.pid):
152
+ raise typer.BadParameter(
153
+ f"wg-bridge is already running on this machine (PID {existing_state.pid}). Stop it first with --stop."
154
+ )
155
+
156
+ mode = "server" if server else "client"
157
+ log_file = log_file.resolve()
158
+ if foreground:
159
+ _run_in_foreground(mode, wg_host, wg_port, tcp_port, log_file)
160
+ else:
161
+ _start_in_background(mode, wg_host, wg_port, tcp_port, log_file)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import select
5
+ import socket
6
+ import time
7
+
8
+ from wg_bridge.framing import recv_framed, send_framed
9
+
10
+
11
+ def run_client(wg_port: int, tcp_port: int) -> None:
12
+ udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
13
+ udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
14
+ udp_sock.bind(("127.0.0.1", wg_port))
15
+
16
+ logging.info("UDP:%d -> TCP:%d bridge ready", wg_port, tcp_port)
17
+
18
+ while True:
19
+ try:
20
+ # Connect out to the SSH tunnel
21
+ tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
22
+ tcp_sock.connect(("127.0.0.1", tcp_port))
23
+
24
+ client_addr = None
25
+ while True:
26
+ r, _, _ = select.select([udp_sock, tcp_sock], [], [], 1.0)
27
+ for s in r:
28
+ if s is udp_sock:
29
+ data, client_addr = udp_sock.recvfrom(65535)
30
+ send_framed(tcp_sock, data)
31
+ elif s is tcp_sock:
32
+ data = recv_framed(tcp_sock)
33
+ if client_addr:
34
+ udp_sock.sendto(data, client_addr)
35
+ except Exception:
36
+ tcp_sock.close()
37
+ time.sleep(0.5) # retry gracefully, never crash
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import struct
5
+
6
+ # Each UDP datagram is prefixed with its length so message boundaries survive
7
+ # the raw TCP byte stream (see run_client/run_server for why this matters).
8
+ _HEADER = struct.Struct("!H")
9
+ MAX_DATAGRAM_SIZE = 2**16 - 1 # fits a 2-byte length prefix; matches the recvfrom(65535) cap
10
+
11
+
12
+ def send_framed(sock: socket.socket, data: bytes) -> None:
13
+ if len(data) > MAX_DATAGRAM_SIZE:
14
+ raise ValueError(f"datagram too large to frame: {len(data)} bytes")
15
+ sock.sendall(_HEADER.pack(len(data)) + data)
16
+
17
+
18
+ def _recv_exact(sock: socket.socket, size: int) -> bytes:
19
+ chunks: list[bytes] = []
20
+ remaining = size
21
+ while remaining > 0:
22
+ chunk = sock.recv(remaining)
23
+ if not chunk:
24
+ raise ConnectionResetError("connection closed while reading a frame")
25
+ chunks.append(chunk)
26
+ remaining -= len(chunk)
27
+ return b"".join(chunks)
28
+
29
+
30
+ def recv_framed(sock: socket.socket) -> bytes:
31
+ (length,) = _HEADER.unpack(_recv_exact(sock, _HEADER.size))
32
+ return _recv_exact(sock, length)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def configure_worker_logging(log_file: Path, mode: str, console: bool) -> None:
9
+ """Configure logging for the process that actually runs the bridge loop.
10
+
11
+ The first line this writes is always the working mode, before any other
12
+ log output from run_server/run_client.
13
+ """
14
+ log_file.parent.mkdir(parents=True, exist_ok=True)
15
+
16
+ file_handler = logging.FileHandler(log_file)
17
+ file_handler.setFormatter(
18
+ logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
19
+ )
20
+
21
+ handlers: list[logging.Handler] = [file_handler]
22
+ if console:
23
+ console_handler = logging.StreamHandler(sys.stdout)
24
+ console_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
25
+ handlers.append(console_handler)
26
+
27
+ root = logging.getLogger()
28
+ root.setLevel(logging.INFO)
29
+ root.handlers = handlers
30
+
31
+ logging.info("Working mode: %s", mode)
32
+
33
+
34
+ def configure_cli_logging() -> None:
35
+ """Configure logging for one-off CLI commands (--stop/--status/start confirmation)."""
36
+ handler = logging.StreamHandler(sys.stdout)
37
+ handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
38
+
39
+ root = logging.getLogger()
40
+ root.setLevel(logging.INFO)
41
+ root.handlers = [handler]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import select
5
+ import socket
6
+ import sys
7
+
8
+ from wg_bridge.framing import recv_framed, send_framed
9
+
10
+
11
+ def run_server(wg_host: str, wg_port: int, tcp_port: int) -> None:
12
+ # 1. Create the TCP listening socket (accepts traffic coming from the SSH tunnel)
13
+ server_tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
14
+ server_tcp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
15
+
16
+ try:
17
+ server_tcp.bind(("0.0.0.0", tcp_port))
18
+ server_tcp.listen(5)
19
+ logging.info("Server bridge started: TCP:%d -> UDP %s:%d", tcp_port, wg_host, wg_port)
20
+ except Exception as e:
21
+ logging.error("Failed to bind TCP port %d: %s", tcp_port, e)
22
+ sys.exit(1)
23
+
24
+ while True:
25
+ try:
26
+ # Wait for the client (connecting in over the SSH tunnel) to connect
27
+ tcp_conn, client_addr = server_tcp.accept()
28
+ logging.info("Accepted connection from SSH tunnel: %s", client_addr)
29
+
30
+ # Create a UDP socket for this TCP connection, forwarding to WireGuard
31
+ udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
32
+
33
+ while True:
34
+ # Wait for either socket to become readable
35
+ r, _, _ = select.select([tcp_conn, udp_sock], [], [], 5.0)
36
+
37
+ if not r:
38
+ continue # timed out, keep looping
39
+
40
+ for s in r:
41
+ # Case A: data from the SSH tunnel (TCP) -> forward to WireGuard (UDP)
42
+ if s is tcp_conn:
43
+ data = recv_framed(tcp_conn)
44
+ udp_sock.sendto(data, (wg_host, wg_port))
45
+
46
+ # Case B: response from WireGuard (UDP) -> forward back over the tunnel (TCP)
47
+ elif s is udp_sock:
48
+ data, _ = udp_sock.recvfrom(65535)
49
+ if data:
50
+ send_framed(tcp_conn, data)
51
+
52
+ except (ConnectionResetError, BrokenPipeError):
53
+ logging.warning(
54
+ "Client disconnected or local WireGuard closed; cleaning up and waiting for the next connection..."
55
+ )
56
+ except Exception as e:
57
+ logging.error("Runtime error: %s", e)
58
+ finally:
59
+ # Release this connection's resources; the main accept loop keeps running
60
+ if "tcp_conn" in locals():
61
+ tcp_conn.close()
62
+ if "udp_sock" in locals():
63
+ udp_sock.close()
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ import signal
7
+ import tempfile
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ STATE_FILE = Path(tempfile.gettempdir()) / "wg-bridge.json"
12
+
13
+
14
+ @dataclass
15
+ class BridgeState:
16
+ pid: int
17
+ mode: str # "server" or "client"
18
+ wg_host: str | None
19
+ wg_port: int
20
+ tcp_port: int
21
+ log_path: str
22
+
23
+
24
+ def write_state(mode: str, wg_port: int, tcp_port: int, log_path: Path, wg_host: str | None = None) -> None:
25
+ state = BridgeState(
26
+ pid=os.getpid(),
27
+ mode=mode,
28
+ wg_host=wg_host,
29
+ wg_port=wg_port,
30
+ tcp_port=tcp_port,
31
+ log_path=str(log_path),
32
+ )
33
+ STATE_FILE.write_text(json.dumps(asdict(state)))
34
+
35
+
36
+ def remove_state() -> None:
37
+ STATE_FILE.unlink(missing_ok=True)
38
+
39
+
40
+ def read_state() -> BridgeState | None:
41
+ try:
42
+ data = json.loads(STATE_FILE.read_text())
43
+ except (FileNotFoundError, ValueError):
44
+ return None
45
+ try:
46
+ return BridgeState(**data)
47
+ except TypeError:
48
+ return None
49
+
50
+
51
+ def describe_binding(mode: str, wg_host: str | None, wg_port: int, tcp_port: int) -> str:
52
+ if mode == "server":
53
+ return f"listening on TCP 0.0.0.0:{tcp_port} -> forwarding to UDP {wg_host}:{wg_port}"
54
+ return f"listening on UDP 127.0.0.1:{wg_port} -> forwarding to TCP 127.0.0.1:{tcp_port}"
55
+
56
+
57
+ def process_running(pid: int) -> bool:
58
+ try:
59
+ os.kill(pid, 0)
60
+ except ProcessLookupError:
61
+ return False
62
+ except PermissionError:
63
+ return True
64
+ return True
65
+
66
+
67
+ def stop_running() -> None:
68
+ state = read_state()
69
+ if state is None:
70
+ logging.info("No wg-bridge process is currently tracked on this machine.")
71
+ return
72
+
73
+ if not process_running(state.pid):
74
+ logging.warning(
75
+ "State file points to PID %d, but no such process is running. Cleaning up stale state.", state.pid
76
+ )
77
+ remove_state()
78
+ return
79
+
80
+ os.kill(state.pid, signal.SIGTERM)
81
+ remove_state()
82
+ logging.info("Stopped wg-bridge process (PID %d).", state.pid)
83
+
84
+
85
+ def print_status() -> None:
86
+ state = read_state()
87
+ if state is None:
88
+ logging.info("wg-bridge is not running on this machine.")
89
+ return
90
+
91
+ if not process_running(state.pid):
92
+ logging.warning(
93
+ "State file points to PID %d, but no such process is running. Cleaning up stale state.", state.pid
94
+ )
95
+ remove_state()
96
+ logging.info("wg-bridge is not running on this machine.")
97
+ return
98
+
99
+ logging.info("wg-bridge is running (PID %d, mode=%s)", state.pid, state.mode)
100
+ logging.info("%s", describe_binding(state.mode, state.wg_host, state.wg_port, state.tcp_port))
101
+ logging.info("logs: %s", state.log_path)