From f76977f5221333f0d1010ea9d362ebb79e50998d Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 14:27:55 -0600 Subject: [PATCH 01/25] feat: add WireWorm NAT hole punching PoC --- WIREWORM.md | 50 +++++++++++++++++++++++++++++++ wireworm.sh | 74 ++++++++++++++++++++++++++++++++++++++++++++++ wireworm_sender.go | 24 +++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 WIREWORM.md create mode 100644 wireworm.sh create mode 100644 wireworm_sender.go diff --git a/WIREWORM.md b/WIREWORM.md new file mode 100644 index 0000000..5006002 --- /dev/null +++ b/WIREWORM.md @@ -0,0 +1,50 @@ +# WireWorm PoC: Userspace WireGuard File Transfer + +WireWorm is a Proof of Concept (PoC) demonstrating how to leverage **wireproxy** (a userspace WireGuard client) for secure, end-to-end encrypted file transfers between two peers behind consumer NATs using **NAT Hole Punching**. + +## How it Works + +Standard file transfer tools often rely on a central relay (like Magic Wormhole's transit relay) to bypass NAT. WireWorm instead uses WireGuard's native ability to punch holes in UDP firewalls. + +1. **Consistent Port Binding**: Both peers bind to a specific local UDP port (e.g., 51820). +2. **UDP Hole Punching**: Both peers simultaneously attempt to send packets to each other's public IP/Port. This "punches" a hole in the NAT firewall. +3. **Userspace Networking**: Using `wireproxy` with `gVisor netstack`, a full TCP/IP stack is established over the WireGuard tunnel entirely in userspace. +4. **Tunnels**: + * The **Sender** exposes a local HTTP file server via a `[TCPServerTunnel]` on the WireGuard interface. + * The **Receiver** maps the sender's WireGuard IP/Port to a local port using a `[TCPClientTunnel]`. + +## Components + +- `wireworm.sh`: A wrapper script that generates WireGuard keys, creates the `wireproxy` configuration, and sets up the tunnels. +- `wireworm_sender.go`: A simple Go-based HTTP server that serves a file for transfer. + +## Usage (Simulated over Internet) + +### 1. Signaling (Exchange Info) +You need to exchange: +- Public IP +- Public Port (UDP) +- WireGuard Public Key + +### 2. Run the Sender +```bash +./wireworm.sh sender +``` + +### 3. Run the Receiver +```bash +./wireworm.sh receiver +``` + +### 4. Transfer the File +On the receiver machine: +```bash +curl http://127.0.0.1:9001/download -o received_file.txt +``` + +## Why WireWorm? + +- **No Root Required**: Everything runs in userspace. +- **VPN Security**: Inherits the Noise Protocol encryption and security of WireGuard. +- **Resilient**: TCP-over-WireGuard handles packet loss and congestion better than raw UDP transfers. +- **Versatile**: Once the tunnel is up, you aren't limited to file transfers. You have a full private network between the two peers. diff --git a/wireworm.sh b/wireworm.sh new file mode 100644 index 0000000..f176fae --- /dev/null +++ b/wireworm.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# wireworm.sh - The Hole Punching Wrapper + +ROLE=$1 # "sender" or "receiver" +PEER_IP=$2 +PEER_PORT=$3 +PEER_PUB=$4 + +if [[ -z "$ROLE" || -z "$PEER_IP" || -z "$PEER_PORT" || -z "$PEER_PUB" ]]; then + echo "Usage: ./wireworm.sh " + exit 1 +fi + +# 1. Local Networking Constants +MY_PRIV=$(wg genkey) +MY_PUB=$(echo "$MY_PRIV" | wg pubkey) +MY_WG_IP="" +PEER_WG_IP="" +LOCAL_PORT=51820 + +if [[ "$ROLE" == "sender" ]]; then + MY_WG_IP="10.0.0.1/32" + PEER_WG_IP="10.0.0.2/32" + + # Configuration for Sender + cat < wireworm.conf +[Interface] +PrivateKey = $MY_PRIV +Address = $MY_WG_IP +ListenPort = $LOCAL_PORT + +[Peer] +PublicKey = $PEER_PUB +Endpoint = $PEER_IP:$PEER_PORT +AllowedIPs = $PEER_WG_IP +PersistentKeepalive = 10 + +# Expose the local file server to the WireGuard network +[TCPServerTunnel] +ListenPort = 9000 +Target = 127.0.0.1:8080 +EOF + echo "Starting Sender File Server..." + # Create a dummy file to send + echo "Hello from WireWorm! This file was transferred via userspace WireGuard hole punching." > wormhole_package.txt + go run wireworm_sender.go wormhole_package.txt & + +else + MY_WG_IP="10.0.0.2/32" + PEER_WG_IP="10.0.0.1/32" + + # Configuration for Receiver + cat < wireworm.conf +[Interface] +PrivateKey = $MY_PRIV +Address = $MY_WG_IP +ListenPort = $LOCAL_PORT + +[Peer] +PublicKey = $PEER_PUB +Endpoint = $PEER_IP:$PEER_PORT +AllowedIPs = $PEER_WG_IP +PersistentKeepalive = 10 + +# Reach the Sender's file server via local port 9001 +[TCPClientTunnel] +BindAddress = 127.0.0.1:9001 +Target = 10.0.0.1:9000 +EOF +fi + +echo "Your PubKey: $MY_PUB" +echo "Starting wireproxy..." +./wireproxy -c wireworm.conf diff --git a/wireworm_sender.go b/wireworm_sender.go new file mode 100644 index 0000000..eb03278 --- /dev/null +++ b/wireworm_sender.go @@ -0,0 +1,24 @@ +package main + +import ( +"fmt" +"net/http" +"os" +) + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: go run sender_server.go ") + return + } + fileName := os.Args[1] + + http.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) { +fmt.Printf("Receiver connected! Sending %s...\n", fileName) +http.ServeFile(w, r, fileName) +}) + + fmt.Println("File server internal listening on 127.0.0.1:8080") + fmt.Println("Endpoint: http://10.0.0.1:9000/download (via WireGuard)") + http.ListenAndServe("127.0.0.1:8080", nil) +} From e8f01d07141f982dcb85b18e9bd0766dbc6565d5 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 14:32:11 -0600 Subject: [PATCH 02/25] feat: add interactive wireworm explorer --- wireworm_interactive.sh | 109 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100755 wireworm_interactive.sh diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh new file mode 100755 index 0000000..b487602 --- /dev/null +++ b/wireworm_interactive.sh @@ -0,0 +1,109 @@ +#!/bin/bash +set -e + +# Colors for "Wow" factor +GREEN='\033[0;32m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +clear +echo -e "${CYAN}====================================================${NC}" +echo -e "${CYAN} 🪱 WIRE-WORM: NAT HOLE PUNCHING PoC ${NC}" +echo -e "${CYAN}====================================================${NC}" +echo "" + +# 1. Dependency Check +if ! command -v wg &> /dev/null; then + echo -e "${RED}Error: 'wg' command (wireguard-tools) not found.${NC}" + exit 1 +fi + +if [ ! -f "./wireproxy" ]; then + echo -e "${YELLOW}Building wireproxy...${NC}" + make > /dev/null +fi + +# 2. Key Generation +PRIV=$(wg genkey) +PUB=$(echo "$PRIV" | wg pubkey) +PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") + +# 3. Mode Selection +echo -e "${BLUE}Who are you?${NC}" +echo "1) Sender (I have a file to send)" +echo "2) Receiver (I want to download a file)" +read -p "Select [1-2]: " MODE + +if [[ "$MODE" == "1" ]]; then + ROLE="sender" + WG_IP="10.0.0.1/32" + PEER_WG_IP="10.0.0.2/32" + LOCAL_WG_PORT=51820 + TUNNEL_INFO="Exposing local file server on WireGuard:9000" +else + ROLE="receiver" + WG_IP="10.0.0.2/32" + PEER_WG_IP="10.0.0.1/32" + LOCAL_WG_PORT=51820 + TUNNEL_INFO="Mapping remote file server to localhost:9001" +fi + +echo -e "\n${GREEN}--- YOUR SIGNAL DATA (Share this with your peer) ---${NC}" +echo -e "${YELLOW}Public IP: ${NC}$PUB_IP" +echo -e "${YELLOW}UDP Port: ${NC}$LOCAL_WG_PORT" +echo -e "${YELLOW}Public Key: ${NC}$PUB" +echo -e "${GREEN}---------------------------------------------------${NC}\n" + +# 4. Input Peer Data +echo -e "${BLUE}Enter Peer Information:${NC}" +read -p "Peer Public IP: " PEER_IP +read -p "Peer UDP Port: $NC" PEER_PORT +read -p "Peer Public Key: $NC" PEER_PUB + +# 5. File selection for sender +FILE_TO_SEND="" +if [[ "$ROLE" == "sender" ]]; then + read -p "File path to send (drag file here): " FILE_INPUT + FILE_TO_SEND=$(echo "$FILE_INPUT" | sed "s/'//g" | sed 's/\\//g') # Clean drag-and-drop paths + if [ ! -f "$FILE_TO_SEND" ]; then + echo -e "${YELLOW}File not found. Using default dummy file.${NC}" + FILE_TO_SEND="wormhole_package.txt" + echo "Hello from WireWorm! This file was transferred via userspace WireGuard hole punching." > "$FILE_TO_SEND" + fi +fi + +# 6. Generate Config +cat < wireworm.conf +[Interface] +PrivateKey = $PRIV +Address = $WG_IP +ListenPort = $LOCAL_WG_PORT + +[Peer] +PublicKey = $PEER_PUB +Endpoint = $PEER_IP:$PEER_PORT +AllowedIPs = $PEER_WG_IP +PersistentKeepalive = 10 +EOF + +if [[ "$ROLE" == "sender" ]]; then + echo -e "\n[TCPServerTunnel]\nListenPort = 9000\nTarget = 127.0.0.1:8080" >> wireworm.conf + echo -e "${GREEN}Starting Receiver-ready file server...${NC}" + go run wireworm_sender.go "$FILE_TO_SEND" & + SERVER_PID=$! + trap 'kill $SERVER_PID 2>/dev/null' EXIT +else + echo -e "\n[TCPClientTunnel]\nBindAddress = 127.0.0.1:9001\nTarget = 10.0.0.1:9000" >> wireworm.conf +fi + +echo -e "${CYAN}PUNCHING HOLE...${NC}" +echo -e "${YELLOW}Wait for 'handshake response' logs, then download the file.${NC}" +if [[ "$ROLE" == "receiver" ]]; then + echo -e "${GREEN}Command to download: ${NC}curl http://127.0.0.1:9001/download -o downloaded_file" +fi +echo "" + +./wireproxy -c wireworm.conf From e9afe5ee57abf22d68704f84051b13128f26fce6 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 14:33:25 -0600 Subject: [PATCH 03/25] fix: relocate test utils to resolve package main conflict and update scripts --- run_test.sh | 27 ++++++++++++++++ test_utils/mock_server.go | 25 +++++++++++++++ test_utils/test_client.go | 32 +++++++++++++++++++ .../wireworm_sender.go | 0 wireworm.sh | 9 ++++-- wireworm_interactive.sh | 2 +- 6 files changed, 91 insertions(+), 4 deletions(-) create mode 100755 run_test.sh create mode 100644 test_utils/mock_server.go create mode 100644 test_utils/test_client.go rename wireworm_sender.go => test_utils/wireworm_sender.go (100%) diff --git a/run_test.sh b/run_test.sh new file mode 100755 index 0000000..4591f78 --- /dev/null +++ b/run_test.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Cleanup on exit +trap 'kill $(jobs -p) 2>/dev/null' EXIT + +echo "Starting Mock Server..." +go run mock_server.go & +MOCK_PID=$! + +echo "Starting Wireproxy Server (Remote Peer)..." +./wireproxy -c server.conf & +SERVER_PID=$! + +echo "Starting Wireproxy Client (Local Peer)..." +./wireproxy -c client.conf & +CLIENT_PID=$! + +echo "Starting Test Client..." +RESPONSE=$(go run test_client.go) +echo "Test Result: $RESPONSE" + +if [[ "$RESPONSE" == *"PONG"* ]]; then + echo "SUCCESS: Integration test passed!" + exit 0 +else + echo "FAILURE: Integration test failed." + exit 1 +fi diff --git a/test_utils/mock_server.go b/test_utils/mock_server.go new file mode 100644 index 0000000..e0ee9cc --- /dev/null +++ b/test_utils/mock_server.go @@ -0,0 +1,25 @@ +package main +import ( + "fmt" + "net" +) +func main() { + l, err := net.Listen("tcp", "127.0.0.1:8080") + if err != nil { + panic(err) + } + fmt.Println("Mock server listening on 8080") + for { + conn, err := l.Accept() + if err != nil { + continue + } + go func(c net.Conn) { + defer c.Close() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + fmt.Printf("Received: %s\n", string(buf[:n])) + c.Write([]byte("PONG")) + }(conn) + } +} diff --git a/test_utils/test_client.go b/test_utils/test_client.go new file mode 100644 index 0000000..c11167a --- /dev/null +++ b/test_utils/test_client.go @@ -0,0 +1,32 @@ +package main +import ( + "fmt" + "net" + "time" +) +func main() { + var conn net.Conn + var err error + for i := 0; i < 15; i++ { + conn, err = net.Dial("tcp", "127.0.0.1:25565") + if err == nil { + break + } + fmt.Printf("Retrying connection... (%d/15)\n", i+1) + time.Sleep(1 * time.Second) + } + + if err != nil { + fmt.Printf("Connection failed: %v\n", err) + return + } + defer conn.Close() + conn.Write([]byte("PING")) + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + fmt.Printf("Read failed: %v\n", err) + return + } + fmt.Printf("Response: %s\n", string(buf[:n])) +} diff --git a/wireworm_sender.go b/test_utils/wireworm_sender.go similarity index 100% rename from wireworm_sender.go rename to test_utils/wireworm_sender.go diff --git a/wireworm.sh b/wireworm.sh index f176fae..a65b3a4 100644 --- a/wireworm.sh +++ b/wireworm.sh @@ -41,9 +41,12 @@ ListenPort = 9000 Target = 127.0.0.1:8080 EOF echo "Starting Sender File Server..." - # Create a dummy file to send - echo "Hello from WireWorm! This file was transferred via userspace WireGuard hole punching." > wormhole_package.txt - go run wireworm_sender.go wormhole_package.txt & + FILE_TO_SEND=${5:-"wormhole_package.txt"} + if [ ! -f "$FILE_TO_SEND" ]; then + echo "Creating dummy bundle: $FILE_TO_SEND" + echo "Hello from WireWorm! This file was transferred via userspace WireGuard hole punching." > "$FILE_TO_SEND" + fi + go run test_utils/wireworm_sender.go "$FILE_TO_SEND" & else MY_WG_IP="10.0.0.2/32" diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index b487602..ed71e0a 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -92,7 +92,7 @@ EOF if [[ "$ROLE" == "sender" ]]; then echo -e "\n[TCPServerTunnel]\nListenPort = 9000\nTarget = 127.0.0.1:8080" >> wireworm.conf echo -e "${GREEN}Starting Receiver-ready file server...${NC}" - go run wireworm_sender.go "$FILE_TO_SEND" & + go run test_utils/wireworm_sender.go "$FILE_TO_SEND" & SERVER_PID=$! trap 'kill $SERVER_PID 2>/dev/null' EXIT else From f6a869ea832209f1450e73ea770203b9fa6e389b Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 14:38:25 -0600 Subject: [PATCH 04/25] fix: improve shell compatibility for interactive prompts --- wireworm_interactive.sh | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index ed71e0a..e8df168 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -35,7 +35,8 @@ PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") echo -e "${BLUE}Who are you?${NC}" echo "1) Sender (I have a file to send)" echo "2) Receiver (I want to download a file)" -read -p "Select [1-2]: " MODE +echo -ne "${YELLOW}Select [1-2]: ${NC}" +read MODE if [[ "$MODE" == "1" ]]; then ROLE="sender" @@ -59,14 +60,18 @@ echo -e "${GREEN}---------------------------------------------------${NC}\n" # 4. Input Peer Data echo -e "${BLUE}Enter Peer Information:${NC}" -read -p "Peer Public IP: " PEER_IP -read -p "Peer UDP Port: $NC" PEER_PORT -read -p "Peer Public Key: $NC" PEER_PUB +echo -ne "${YELLOW}Peer Public IP: ${NC}" +read PEER_IP +echo -ne "${YELLOW}Peer UDP Port: ${NC}" +read PEER_PORT +echo -ne "${YELLOW}Peer Public Key: ${NC}" +read PEER_PUB # 5. File selection for sender FILE_TO_SEND="" if [[ "$ROLE" == "sender" ]]; then - read -p "File path to send (drag file here): " FILE_INPUT + echo -ne "${YELLOW}File path to send (drag file here): ${NC}" + read FILE_INPUT FILE_TO_SEND=$(echo "$FILE_INPUT" | sed "s/'//g" | sed 's/\\//g') # Clean drag-and-drop paths if [ ! -f "$FILE_TO_SEND" ]; then echo -e "${YELLOW}File not found. Using default dummy file.${NC}" From beba019309b438b52382391ffc2ed020da4edf73 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 14:38:45 -0600 Subject: [PATCH 05/25] fix: add input validation and sanitization to interactive PoC --- wireworm_interactive.sh | 77 +++++++++++++++++++++++++++++++++-------- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index e8df168..9b76215 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -31,25 +31,57 @@ PRIV=$(wg genkey) PUB=$(echo "$PRIV" | wg pubkey) PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") +# --- Validation Functions --- +validate_ip() { + local ip=$1 + # Simple regex for IPv4 or hostname + if [[ $ip =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || [[ $ip =~ ^[a-zA-Z0-9.-]+$ ]]; then + return 0 + fi + return 1 +} + +validate_port() { + if [[ $1 =~ ^[0-9]+$ ]] && [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; then + return 0 + fi + return 1 +} + +validate_pubkey() { + # WireGuard keys are 44 chars including base64 padding + if [[ $1 =~ ^[a-zA-Z0-9+/]{42,43}=$ ]]; then + return 0 + fi + return 1 +} + +sanitize() { + echo "$1" | tr -d '[:cntrl:]' | xargs +} + # 3. Mode Selection -echo -e "${BLUE}Who are you?${NC}" -echo "1) Sender (I have a file to send)" -echo "2) Receiver (I want to download a file)" -echo -ne "${YELLOW}Select [1-2]: ${NC}" -read MODE +while true; do + echo -e "${BLUE}Who are you?${NC}" + echo "1) Sender (I have a file to send)" + echo "2) Receiver (I want to download a file)" + echo -ne "${YELLOW}Select [1-2]: ${NC}" + read MODE + MODE=$(sanitize "$MODE") + if [[ "$MODE" == "1" || "$MODE" == "2" ]]; then break; fi + echo -e "${RED}Invalid selection.${NC}" +done if [[ "$MODE" == "1" ]]; then ROLE="sender" WG_IP="10.0.0.1/32" PEER_WG_IP="10.0.0.2/32" LOCAL_WG_PORT=51820 - TUNNEL_INFO="Exposing local file server on WireGuard:9000" else ROLE="receiver" WG_IP="10.0.0.2/32" PEER_WG_IP="10.0.0.1/32" LOCAL_WG_PORT=51820 - TUNNEL_INFO="Mapping remote file server to localhost:9001" fi echo -e "\n${GREEN}--- YOUR SIGNAL DATA (Share this with your peer) ---${NC}" @@ -60,19 +92,36 @@ echo -e "${GREEN}---------------------------------------------------${NC}\n" # 4. Input Peer Data echo -e "${BLUE}Enter Peer Information:${NC}" -echo -ne "${YELLOW}Peer Public IP: ${NC}" -read PEER_IP -echo -ne "${YELLOW}Peer UDP Port: ${NC}" -read PEER_PORT -echo -ne "${YELLOW}Peer Public Key: ${NC}" -read PEER_PUB +while true; do + echo -ne "${YELLOW}Peer Public IP: ${NC}" + read PEER_IP + PEER_IP=$(sanitize "$PEER_IP") + if validate_ip "$PEER_IP"; then break; fi + echo -e "${RED}Invalid IP or Hostname.${NC}" +done + +while true; do + echo -ne "${YELLOW}Peer UDP Port: ${NC}" + read PEER_PORT + PEER_PORT=$(sanitize "$PEER_PORT") + if validate_port "$PEER_PORT"; then break; fi + echo -e "${RED}Invalid Port (1-65535).${NC}" +done + +while true; do + echo -ne "${YELLOW}Peer Public Key: ${NC}" + read PEER_PUB + PEER_PUB=$(sanitize "$PEER_PUB") + if validate_pubkey "$PEER_PUB"; then break; fi + echo -e "${RED}Invalid WireGuard Public Key.${NC}" +done # 5. File selection for sender FILE_TO_SEND="" if [[ "$ROLE" == "sender" ]]; then echo -ne "${YELLOW}File path to send (drag file here): ${NC}" read FILE_INPUT - FILE_TO_SEND=$(echo "$FILE_INPUT" | sed "s/'//g" | sed 's/\\//g') # Clean drag-and-drop paths + FILE_TO_SEND=$(echo "$FILE_INPUT" | sed "s/'//g" | sed 's/\\//g' | xargs) if [ ! -f "$FILE_TO_SEND" ]; then echo -e "${YELLOW}File not found. Using default dummy file.${NC}" FILE_TO_SEND="wormhole_package.txt" From 7a80db4ce2176615638856b035b428cf86270243 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:17:49 -0600 Subject: [PATCH 06/25] fix: resolve hole punching 'signal mismatch' and add connection monitor --- wireworm.sh | 12 +++++++ wireworm_interactive.sh | 76 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/wireworm.sh b/wireworm.sh index a65b3a4..441ac5c 100644 --- a/wireworm.sh +++ b/wireworm.sh @@ -18,6 +18,18 @@ MY_WG_IP="" PEER_WG_IP="" LOCAL_PORT=51820 +# discovery +echo "Discovering NAT mapping via STUN..." +STUN_OUT=$(stunclient --localport $LOCAL_PORT stun.l.google.com 19302 2>&1 || echo "") +PUB_IP=$(echo "$STUN_OUT" | grep "Mapped address" | cut -d' ' -f3 | cut -d':' -f1) +PUB_PORT=$(echo "$STUN_OUT" | grep "Mapped address" | cut -d' ' -f3 | cut -d':' -f2) + +if [[ -n "$PUB_IP" ]]; then + echo "Your External IP: $PUB_IP" + echo "Your External Port: $PUB_PORT" + echo "SHARE THIS WITH PEER!" +fi + if [[ "$ROLE" == "sender" ]]; then MY_WG_IP="10.0.0.1/32" PEER_WG_IP="10.0.0.2/32" diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index 9b76215..fd0fa41 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -21,6 +21,15 @@ if ! command -v wg &> /dev/null; then exit 1 fi +if ! command -v stunclient &> /dev/null; then + echo -e "${YELLOW}Installing STUN client for NAT discovery...${NC}" + if [[ "$OSTYPE" == "darwin"* ]]; then + brew install stuntman > /dev/null + else + sudo apt-get update && sudo apt-get install -y stun-client > /dev/null + fi +fi + if [ ! -f "./wireproxy" ]; then echo -e "${YELLOW}Building wireproxy...${NC}" make > /dev/null @@ -29,7 +38,19 @@ fi # 2. Key Generation PRIV=$(wg genkey) PUB=$(echo "$PRIV" | wg pubkey) -PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") + +# --- NAT Discovery --- +LOCAL_WG_PORT=51820 +echo -e "${BLUE}Discovering NAT mapping...${NC}" +STUN_OUT=$(stunclient --localport $LOCAL_WG_PORT stun.l.google.com 19302 2>&1 || echo "") +PUB_IP=$(echo "$STUN_OUT" | grep "Mapped address" | cut -d' ' -f3 | cut -d':' -f1) +PUB_PORT=$(echo "$STUN_OUT" | grep "Mapped address" | cut -d' ' -f3 | cut -d':' -f2) + +if [[ -z "$PUB_IP" || -z "$PUB_PORT" ]]; then + echo -e "${YELLOW}Warning: STUN discovery failed. Falling back to simple IP discovery.${NC}" + PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") + PUB_PORT=$LOCAL_WG_PORT +fi # --- Validation Functions --- validate_ip() { @@ -76,20 +97,30 @@ if [[ "$MODE" == "1" ]]; then ROLE="sender" WG_IP="10.0.0.1/32" PEER_WG_IP="10.0.0.2/32" - LOCAL_WG_PORT=51820 else ROLE="receiver" WG_IP="10.0.0.2/32" PEER_WG_IP="10.0.0.1/32" - LOCAL_WG_PORT=51820 fi echo -e "\n${GREEN}--- YOUR SIGNAL DATA (Share this with your peer) ---${NC}" echo -e "${YELLOW}Public IP: ${NC}$PUB_IP" -echo -e "${YELLOW}UDP Port: ${NC}$LOCAL_WG_PORT" +echo -e "${YELLOW}UDP Port: ${NC}$PUB_PORT" echo -e "${YELLOW}Public Key: ${NC}$PUB" echo -e "${GREEN}---------------------------------------------------${NC}\n" +# Start a background "Hole Maintainer" to keep the NAT mapping from expiring +# while the user is typing the peer information. +( + while true; do + # Sending a tiny packet to the stun server to keep the mapping active + stunclient --localport $LOCAL_WG_PORT stun.l.google.com 19302 &> /dev/null + sleep 20 + done +) & +MAINTAINER_PID=$! +trap 'kill $MAINTAINER_PID 2>/dev/null || true' EXIT + # 4. Input Peer Data echo -e "${BLUE}Enter Peer Information:${NC}" while true; do @@ -148,7 +179,6 @@ if [[ "$ROLE" == "sender" ]]; then echo -e "${GREEN}Starting Receiver-ready file server...${NC}" go run test_utils/wireworm_sender.go "$FILE_TO_SEND" & SERVER_PID=$! - trap 'kill $SERVER_PID 2>/dev/null' EXIT else echo -e "\n[TCPClientTunnel]\nBindAddress = 127.0.0.1:9001\nTarget = 10.0.0.1:9000" >> wireworm.conf fi @@ -158,6 +188,38 @@ echo -e "${YELLOW}Wait for 'handshake response' logs, then download the file.${N if [[ "$ROLE" == "receiver" ]]; then echo -e "${GREEN}Command to download: ${NC}curl http://127.0.0.1:9001/download -o downloaded_file" fi -echo "" -./wireproxy -c wireworm.conf +# Start wireproxy with the info server enabled for handshake monitoring +./wireproxy -c wireworm.conf -i 127.0.0.1:8081 > wireproxy.log 2>&1 & +WIREPROXY_PID=$! + +# Handshake Monitor Loop +echo -e "${BLUE}Monitoring Connection Status...${NC}" +( + while kill -0 $WIREPROXY_PID 2>/dev/null; do + METRICS=$(curl -s http://127.0.0.1:8081/metrics || echo "") + HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 || echo "0") + + if [ "$HANDSHAKE" -ne "0" ] && [ ! -z "$HANDSHAKE" ]; then + echo -e "\n${GREEN}====================================================${NC}" + echo -e "${GREEN} šŸš€ SUCCESS: HOLE PUNCHED! ${NC}" + echo -e "${GREEN}====================================================${NC}" + echo -e "${CYAN}Handshake established at: $(date -r $HANDSHAKE)${NC}" + echo -e "${YELLOW}WireWorm tunnel is active.${NC}" + if [[ "$ROLE" == "receiver" ]]; then + echo -e "${GREEN}You can now run the curl command in another terminal.${NC}" + fi + # Keep monitoring but slow down + sleep 60 + else + echo -ne "${YELLOW}Listening for peer... (No handshake yet) \r${NC}" + fi + sleep 2 + done +) & +MONITOR_PID=$! + +# Handle shutdown +trap 'kill $WIREPROXY_PID $SERVER_PID $MAINTAINER_PID $MONITOR_PID 2>/dev/null || true; exit' INT TERM EXIT + +wait $WIREPROXY_PID From 2cc70120efe63f0c179b864dab888b53adfcd9f6 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:19:48 -0600 Subject: [PATCH 07/25] feat: simplify peer exchange with unified connection string --- wireworm_interactive.sh | 39 ++++++++++++++------------------------- 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index fd0fa41..28c52e6 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -103,10 +103,8 @@ else PEER_WG_IP="10.0.0.1/32" fi -echo -e "\n${GREEN}--- YOUR SIGNAL DATA (Share this with your peer) ---${NC}" -echo -e "${YELLOW}Public IP: ${NC}$PUB_IP" -echo -e "${YELLOW}UDP Port: ${NC}$PUB_PORT" -echo -e "${YELLOW}Public Key: ${NC}$PUB" +echo -e "\n${GREEN}--- YOUR CONNECTION STRING (Share this with your peer) ---${NC}" +echo -e "${YELLOW}CONNECTION:${NC} ${CYAN}$PUB_IP:$PUB_PORT:$PUB${NC}" echo -e "${GREEN}---------------------------------------------------${NC}\n" # Start a background "Hole Maintainer" to keep the NAT mapping from expiring @@ -124,27 +122,18 @@ trap 'kill $MAINTAINER_PID 2>/dev/null || true' EXIT # 4. Input Peer Data echo -e "${BLUE}Enter Peer Information:${NC}" while true; do - echo -ne "${YELLOW}Peer Public IP: ${NC}" - read PEER_IP - PEER_IP=$(sanitize "$PEER_IP") - if validate_ip "$PEER_IP"; then break; fi - echo -e "${RED}Invalid IP or Hostname.${NC}" -done - -while true; do - echo -ne "${YELLOW}Peer UDP Port: ${NC}" - read PEER_PORT - PEER_PORT=$(sanitize "$PEER_PORT") - if validate_port "$PEER_PORT"; then break; fi - echo -e "${RED}Invalid Port (1-65535).${NC}" -done - -while true; do - echo -ne "${YELLOW}Peer Public Key: ${NC}" - read PEER_PUB - PEER_PUB=$(sanitize "$PEER_PUB") - if validate_pubkey "$PEER_PUB"; then break; fi - echo -e "${RED}Invalid WireGuard Public Key.${NC}" + echo -ne "${YELLOW}Paste Peer Connection String: ${NC}" + read PEER_INPUT + PEER_INPUT=$(sanitize "$PEER_INPUT") + + PEER_IP=$(echo "$PEER_INPUT" | cut -d':' -f1) + PEER_PORT=$(echo "$PEER_INPUT" | cut -d':' -f2) + PEER_PUB=$(echo "$PEER_INPUT" | cut -d':' -f3) + + if validate_ip "$PEER_IP" && validate_port "$PEER_PORT" && validate_pubkey "$PEER_PUB"; then + break + fi + echo -e "${RED}Invalid connection string. Expected format: IP:PORT:PUBKEY${NC}" done # 5. File selection for sender From 6ae3745ef1a17cde14dc195b347ef98a81b15672 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:22:41 -0600 Subject: [PATCH 08/25] fix: randomize local port for discovery to avoid conflicts and verify mapping --- wireworm_interactive.sh | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index 28c52e6..8311d0a 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -40,11 +40,25 @@ PRIV=$(wg genkey) PUB=$(echo "$PRIV" | wg pubkey) # --- NAT Discovery --- -LOCAL_WG_PORT=51820 -echo -e "${BLUE}Discovering NAT mapping...${NC}" -STUN_OUT=$(stunclient --localport $LOCAL_WG_PORT stun.l.google.com 19302 2>&1 || echo "") -PUB_IP=$(echo "$STUN_OUT" | grep "Mapped address" | cut -d' ' -f3 | cut -d':' -f1) -PUB_PORT=$(echo "$STUN_OUT" | grep "Mapped address" | cut -d' ' -f3 | cut -d':' -f2) +# Use a random local port for better punch-through and to avoid conflicts +LOCAL_WG_PORT=$((RANDOM % 55535 + 10000)) + +echo -e "${BLUE}Discovering NAT mapping using local port $LOCAL_WG_PORT...${NC}" +# Try multiple servers if one is down +STUN_SERVERS=("stun.l.google.com:19302" "stunserver.org:3478" "stun.voip.blackberry.com:3478") +STUN_OUT="" + +for s in "${STUN_SERVERS[@]}"; do + server=$(echo $s | cut -d':' -f1) + port=$(echo $s | cut -d':' -f2) + STUN_OUT=$(stunclient --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "") + if [[ "$STUN_OUT" == *"Mapped address"* ]]; then + break + fi +done + +PUB_IP=$(echo "$STUN_OUT" | grep -oE "Mapped address: [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+" | cut -d' ' -f3 | cut -d':' -f1 || echo "") +PUB_PORT=$(echo "$STUN_OUT" | grep -oE "Mapped address: [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+" | cut -d' ' -f3 | cut -d':' -f2 || echo "") if [[ -z "$PUB_IP" || -z "$PUB_PORT" ]]; then echo -e "${YELLOW}Warning: STUN discovery failed. Falling back to simple IP discovery.${NC}" From ccfb6fb90ba4d08665d14abf9331ab6c9488d810 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:28:16 -0600 Subject: [PATCH 09/25] feat: add P2P encrypted chat mode to WireWorm --- WIREWORM.md | 43 +++++++------------- test_utils/wireworm_chat.go | 67 ++++++++++++++++++++++++++++++ wireworm_interactive.sh | 81 ++++++++++++++++++++++++++----------- 3 files changed, 140 insertions(+), 51 deletions(-) create mode 100644 test_utils/wireworm_chat.go diff --git a/WIREWORM.md b/WIREWORM.md index 5006002..66b0cf8 100644 --- a/WIREWORM.md +++ b/WIREWORM.md @@ -13,38 +13,25 @@ Standard file transfer tools often rely on a central relay (like Magic Wormhole' * The **Sender** exposes a local HTTP file server via a `[TCPServerTunnel]` on the WireGuard interface. * The **Receiver** maps the sender's WireGuard IP/Port to a local port using a `[TCPClientTunnel]`. -## Components +## Features -- `wireworm.sh`: A wrapper script that generates WireGuard keys, creates the `wireproxy` configuration, and sets up the tunnels. -- `wireworm_sender.go`: A simple Go-based HTTP server that serves a file for transfer. +- **P2P File Transfer**: High-speed, secure file transfers using standard HTTP over WireGuard. +- **Instant Chat**: Secure, private, end-to-end encrypted chat session between peers. +- **No Root Required**: Everything runs in userspace. +- **NAT Traversal**: Automatic UDP hole punching to bypass restrictive firewalls. -## Usage (Simulated over Internet) +## Usage (Interactive) -### 1. Signaling (Exchange Info) -You need to exchange: -- Public IP -- Public Port (UDP) -- WireGuard Public Key +The easiest way to use WireWorm is via the interactive script: -### 2. Run the Sender ```bash -./wireworm.sh sender +cd wireproxy +bash wireworm_interactive.sh ``` -### 3. Run the Receiver -```bash -./wireworm.sh receiver -``` - -### 4. Transfer the File -On the receiver machine: -```bash -curl http://127.0.0.1:9001/download -o received_file.txt -``` - -## Why WireWorm? - -- **No Root Required**: Everything runs in userspace. -- **VPN Security**: Inherits the Noise Protocol encryption and security of WireGuard. -- **Resilient**: TCP-over-WireGuard handles packet loss and congestion better than raw UDP transfers. -- **Versatile**: Once the tunnel is up, you aren't limited to file transfers. You have a full private network between the two peers. +1. **Select Mode**: Choose between File Transfer or Chat. +2. **Exchange Connection String**: The script will provide a single string (e.g., `IP:PORT:PUBKEY`) to share with your peer. +3. **Establish Secure Tunnel**: Once both peers enter each other's strings, the NAT hole is punched and the WireGuard handshake begins. +4. **Interact**: + * **In Chat Mode**: The chat session will begin automatically in your terminal. + * **In File Mode**: Use the provided `curl` command to download the file. diff --git a/test_utils/wireworm_chat.go b/test_utils/wireworm_chat.go new file mode 100644 index 0000000..9d23620 --- /dev/null +++ b/test_utils/wireworm_chat.go @@ -0,0 +1,67 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "log" + "net" + "os" + "strings" +) + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage:") + fmt.Println(" Server: go run wireworm_chat.go server ") + fmt.Println(" Client: go run wireworm_chat.go client ") + return + } + + mode := os.Args[1] + target := os.Args[2] + + var conn net.Conn + var err error + + if mode == "server" { + fmt.Printf("Chat Server listening on 127.0.0.1:%s...\n", target) + ln, err := net.Listen("tcp", "127.0.0.1:"+target) + if err != nil { + log.Fatal(err) + } + conn, err = ln.Accept() + if err != nil { + log.Fatal(err) + } + fmt.Println("Peer connected! Start typing (Ctrl+C to quit).") + } else { + fmt.Printf("Connecting to Chat Server at %s...\n", target) + conn, err = net.Dial("tcp", target) + if err != nil { + log.Fatal(err) + } + fmt.Println("Connected to peer! Start typing (Ctrl+C to quit).") + } + + defer conn.Close() + + // 2-way communication + go func() { + _, _ = io.Copy(os.Stdout, conn) + fmt.Println("\nPeer disconnected.") + os.Exit(0) + }() + + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + text := scanner.Text() + if strings.TrimSpace(text) == "" { + continue + } + _, err := fmt.Fprintln(conn, "Peer: "+text) + if err != nil { + break + } + } +} diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index 8311d0a..f6f0d6d 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -97,24 +97,28 @@ sanitize() { # 3. Mode Selection while true; do - echo -e "${BLUE}Who are you?${NC}" - echo "1) Sender (I have a file to send)" - echo "2) Receiver (I want to download a file)" - echo -ne "${YELLOW}Select [1-2]: ${NC}" + echo -e "${BLUE}What would you like to do?${NC}" + echo "1) Send File" + echo "2) Receive File" + echo "3) Start Chat (Host)" + echo "4) Join Chat" + echo -ne "${YELLOW}Select [1-4]: ${NC}" read MODE MODE=$(sanitize "$MODE") - if [[ "$MODE" == "1" || "$MODE" == "2" ]]; then break; fi + if [[ "$MODE" =~ ^[1-4]$ ]]; then break; fi echo -e "${RED}Invalid selection.${NC}" done -if [[ "$MODE" == "1" ]]; then - ROLE="sender" +if [[ "$MODE" == "1" || "$MODE" == "3" ]]; then + ROLE="host" WG_IP="10.0.0.1/32" PEER_WG_IP="10.0.0.2/32" + if [[ "$MODE" == "1" ]]; then SUB_MODE="file"; else SUB_MODE="chat"; fi else - ROLE="receiver" + ROLE="joiner" WG_IP="10.0.0.2/32" PEER_WG_IP="10.0.0.1/32" + if [[ "$MODE" == "2" ]]; then SUB_MODE="file"; else SUB_MODE="chat"; fi fi echo -e "\n${GREEN}--- YOUR CONNECTION STRING (Share this with your peer) ---${NC}" @@ -152,7 +156,7 @@ done # 5. File selection for sender FILE_TO_SEND="" -if [[ "$ROLE" == "sender" ]]; then +if [[ "$SUB_MODE" == "file" && "$ROLE" == "host" ]]; then echo -ne "${YELLOW}File path to send (drag file here): ${NC}" read FILE_INPUT FILE_TO_SEND=$(echo "$FILE_INPUT" | sed "s/'//g" | sed 's/\\//g' | xargs) @@ -177,19 +181,33 @@ AllowedIPs = $PEER_WG_IP PersistentKeepalive = 10 EOF -if [[ "$ROLE" == "sender" ]]; then - echo -e "\n[TCPServerTunnel]\nListenPort = 9000\nTarget = 127.0.0.1:8080" >> wireworm.conf - echo -e "${GREEN}Starting Receiver-ready file server...${NC}" - go run test_utils/wireworm_sender.go "$FILE_TO_SEND" & - SERVER_PID=$! +if [[ "$ROLE" == "host" ]]; then + if [[ "$SUB_MODE" == "file" ]]; then + echo -e "\n[TCPServerTunnel]\nListenPort = 9000\nTarget = 127.0.0.1:8080" >> wireworm.conf + echo -e "${GREEN}Starting Receiver-ready file server...${NC}" + go run test_utils/wireworm_sender.go "$FILE_TO_SEND" & + SERVER_PID=$! + else + echo -e "\n[TCPServerTunnel]\nListenPort = 9002\nTarget = 127.0.0.1:8082" >> wireworm.conf + echo -e "${GREEN}Preparing Chat Host...${NC}" + # We will start the actual chat tool AFTER wireproxy is up + fi else - echo -e "\n[TCPClientTunnel]\nBindAddress = 127.0.0.1:9001\nTarget = 10.0.0.1:9000" >> wireworm.conf + if [[ "$SUB_MODE" == "file" ]]; then + echo -e "\n[TCPClientTunnel]\nBindAddress = 127.0.0.1:9001\nTarget = 10.0.0.1:9000" >> wireworm.conf + else + echo -e "\n[TCPClientTunnel]\nBindAddress = 127.0.0.1:9003\nTarget = 10.0.0.1:9002" >> wireworm.conf + fi fi echo -e "${CYAN}PUNCHING HOLE...${NC}" -echo -e "${YELLOW}Wait for 'handshake response' logs, then download the file.${NC}" -if [[ "$ROLE" == "receiver" ]]; then - echo -e "${GREEN}Command to download: ${NC}curl http://127.0.0.1:9001/download -o downloaded_file" +if [[ "$SUB_MODE" == "file" ]]; then + echo -e "${YELLOW}Wait for 'handshake response' logs, then download the file.${NC}" + if [[ "$ROLE" == "joiner" ]]; then + echo -e "${GREEN}Command to download: ${NC}curl http://127.0.0.1:9001/download -o downloaded_file" + fi +else + echo -e "${YELLOW}Wait for handshake, then chat will begin.${NC}" fi # Start wireproxy with the info server enabled for handshake monitoring @@ -207,13 +225,30 @@ echo -e "${BLUE}Monitoring Connection Status...${NC}" echo -e "\n${GREEN}====================================================${NC}" echo -e "${GREEN} šŸš€ SUCCESS: HOLE PUNCHED! ${NC}" echo -e "${GREEN}====================================================${NC}" - echo -e "${CYAN}Handshake established at: $(date -r $HANDSHAKE)${NC}" + if [[ "$OSTYPE" == "darwin"* ]]; then + HS_TIME=$(date -r $HANDSHAKE) + else + HS_TIME=$(date -d @$HANDSHAKE) + fi + echo -e "${CYAN}Handshake established at: $HS_TIME${NC}" echo -e "${YELLOW}WireWorm tunnel is active.${NC}" - if [[ "$ROLE" == "receiver" ]]; then - echo -e "${GREEN}You can now run the curl command in another terminal.${NC}" + if [[ "$SUB_MODE" == "file" ]]; then + if [[ "$ROLE" == "joiner" ]]; then + echo -e "${GREEN}You can now run the curl command in another terminal.${NC}" + fi + # Keep monitoring but slow down + sleep 60 + else + echo -e "${GREEN}Starting Chat Session...${NC}" + if [[ "$ROLE" == "host" ]]; then + go run test_utils/wireworm_chat.go server 8082 + else + go run test_utils/wireworm_chat.go client 127.0.0.1:9003 + fi + # Once chat exits, kill everything + kill $WIREPROXY_PID 2>/dev/null || true + exit 0 fi - # Keep monitoring but slow down - sleep 60 else echo -ne "${YELLOW}Listening for peer... (No handshake yet) \r${NC}" fi From aff079be0c14009a4a773a08ae214e0e792cc059 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:29:57 -0600 Subject: [PATCH 10/25] fix: resolve handshake monitor shell errors and redundant logging --- wireworm_interactive.sh | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index f6f0d6d..9a3e5fb 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -217,27 +217,36 @@ WIREPROXY_PID=$! # Handshake Monitor Loop echo -e "${BLUE}Monitoring Connection Status...${NC}" ( + CONNECTED=false while kill -0 $WIREPROXY_PID 2>/dev/null; do METRICS=$(curl -s http://127.0.0.1:8081/metrics || echo "") - HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 || echo "0") + HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 | xargs || echo "0") - if [ "$HANDSHAKE" -ne "0" ] && [ ! -z "$HANDSHAKE" ]; then - echo -e "\n${GREEN}====================================================${NC}" - echo -e "${GREEN} šŸš€ SUCCESS: HOLE PUNCHED! ${NC}" - echo -e "${GREEN}====================================================${NC}" - if [[ "$OSTYPE" == "darwin"* ]]; then - HS_TIME=$(date -r $HANDSHAKE) - else - HS_TIME=$(date -d @$HANDSHAKE) + # Ensure HANDSHAKE is a valid number + if [[ ! "$HANDSHAKE" =~ ^[0-9]+$ ]]; then HANDSHAKE=0; fi + + if [ "$HANDSHAKE" -gt 0 ]; then + if [ "$CONNECTED" = false ]; then + echo -e "\n${GREEN}====================================================${NC}" + echo -e "${GREEN} šŸš€ SUCCESS: HOLE PUNCHED! ${NC}" + echo -e "${GREEN}====================================================${NC}" + + if [[ "$OSTYPE" == "darwin"* ]]; then + HS_TIME=$(date -r "$HANDSHAKE" 2>/dev/null || echo "Unknown") + else + HS_TIME=$(date -d @"$HANDSHAKE" 2>/dev/null || echo "Unknown") + fi + echo -e "${CYAN}Handshake established at: $HS_TIME${NC}" + echo -e "${YELLOW}WireWorm tunnel is active.${NC}" + CONNECTED=true fi - echo -e "${CYAN}Handshake established at: $HS_TIME${NC}" - echo -e "${YELLOW}WireWorm tunnel is active.${NC}" + if [[ "$SUB_MODE" == "file" ]]; then if [[ "$ROLE" == "joiner" ]]; then echo -e "${GREEN}You can now run the curl command in another terminal.${NC}" fi - # Keep monitoring but slow down - sleep 60 + # Handshake succeeded, we can slow down metrics polling significantly + sleep 30 else echo -e "${GREEN}Starting Chat Session...${NC}" if [[ "$ROLE" == "host" ]]; then From adc95397859dc3983b03f4ef226b38b871f72e21 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:31:11 -0600 Subject: [PATCH 11/25] refactor: run chat in foreground for cleaner exit and terminal control --- wireworm_interactive.sh | 99 +++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index 9a3e5fb..4177c94 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -214,59 +214,70 @@ fi ./wireproxy -c wireworm.conf -i 127.0.0.1:8081 > wireproxy.log 2>&1 & WIREPROXY_PID=$! +# Handle shutdown +trap 'kill $WIREPROXY_PID $SERVER_PID $MAINTAINER_PID $MONITOR_PID 2>/dev/null || true; exit' INT TERM EXIT + # Handshake Monitor Loop -echo -e "${BLUE}Monitoring Connection Status...${NC}" -( - CONNECTED=false +if [[ "$SUB_MODE" == "file" ]]; then + echo -e "${BLUE}Monitoring Connection Status...${NC}" + ( + CONNECTED=false + while kill -0 $WIREPROXY_PID 2>/dev/null; do + METRICS=$(curl -s http://127.0.0.1:8081/metrics || echo "") + HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 | xargs || echo "0") + if [[ ! "$HANDSHAKE" =~ ^[0-9]+$ ]]; then HANDSHAKE=0; fi + + if [ "$HANDSHAKE" -gt 0 ]; then + if [ "$CONNECTED" = false ]; then + echo -e "\n${GREEN}====================================================${NC}" + echo -e "${GREEN} šŸš€ SUCCESS: HOLE PUNCHED! ${NC}" + echo -e "${GREEN}====================================================${NC}" + if [[ "$OSTYPE" == "darwin"* ]]; then + HS_TIME=$(date -r "$HANDSHAKE" 2>/dev/null || echo "Unknown") + else + HS_TIME=$(date -d @"$HANDSHAKE" 2>/dev/null || echo "Unknown") + fi + echo -e "${CYAN}Handshake established at: $HS_TIME${NC}" + echo -e "${YELLOW}WireWorm tunnel is active.${NC}" + if [[ "$ROLE" == "joiner" ]]; then + echo -e "${GREEN}You can now run the curl command in another terminal.${NC}" + fi + CONNECTED=true + fi + sleep 30 + else + echo -ne "${YELLOW}Listening for peer... (No handshake yet) \r${NC}" + fi + sleep 2 + done + ) & + MONITOR_PID=$! + wait $WIREPROXY_PID +else + # Chat mode: Monitor in foreground, then launch chat + echo -e "${BLUE}Waiting for peer to connect...${NC}" while kill -0 $WIREPROXY_PID 2>/dev/null; do METRICS=$(curl -s http://127.0.0.1:8081/metrics || echo "") HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 | xargs || echo "0") - - # Ensure HANDSHAKE is a valid number if [[ ! "$HANDSHAKE" =~ ^[0-9]+$ ]]; then HANDSHAKE=0; fi if [ "$HANDSHAKE" -gt 0 ]; then - if [ "$CONNECTED" = false ]; then - echo -e "\n${GREEN}====================================================${NC}" - echo -e "${GREEN} šŸš€ SUCCESS: HOLE PUNCHED! ${NC}" - echo -e "${GREEN}====================================================${NC}" - - if [[ "$OSTYPE" == "darwin"* ]]; then - HS_TIME=$(date -r "$HANDSHAKE" 2>/dev/null || echo "Unknown") - else - HS_TIME=$(date -d @"$HANDSHAKE" 2>/dev/null || echo "Unknown") - fi - echo -e "${CYAN}Handshake established at: $HS_TIME${NC}" - echo -e "${YELLOW}WireWorm tunnel is active.${NC}" - CONNECTED=true - fi - - if [[ "$SUB_MODE" == "file" ]]; then - if [[ "$ROLE" == "joiner" ]]; then - echo -e "${GREEN}You can now run the curl command in another terminal.${NC}" - fi - # Handshake succeeded, we can slow down metrics polling significantly - sleep 30 + echo -e "\n${GREEN}šŸš€ SUCCESS: HOLE PUNCHED!${NC}" + echo -e "${GREEN}Starting Chat Session...${NC}" + # Kill the maintainer before chat starts to avoid port use/interference + kill $MAINTAINER_PID 2>/dev/null || true + if [[ "$ROLE" == "host" ]]; then + go run test_utils/wireworm_chat.go server 8082 else - echo -e "${GREEN}Starting Chat Session...${NC}" - if [[ "$ROLE" == "host" ]]; then - go run test_utils/wireworm_chat.go server 8082 - else - go run test_utils/wireworm_chat.go client 127.0.0.1:9003 - fi - # Once chat exits, kill everything - kill $WIREPROXY_PID 2>/dev/null || true - exit 0 + go run test_utils/wireworm_chat.go client 127.0.0.1:9003 fi - else - echo -ne "${YELLOW}Listening for peer... (No handshake yet) \r${NC}" + break fi + echo -ne "${YELLOW}Listening for peer... (No handshake yet) \r${NC}" sleep 2 done -) & -MONITOR_PID=$! - -# Handle shutdown -trap 'kill $WIREPROXY_PID $SERVER_PID $MAINTAINER_PID $MONITOR_PID 2>/dev/null || true; exit' INT TERM EXIT - -wait $WIREPROXY_PID + + # Cleanup and exit cleanly + kill $WIREPROXY_PID 2>/dev/null || true + exit 0 +fi From f76a2d57bbf421c73dde04ddbb1b471c053c1d9f Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:35:27 -0600 Subject: [PATCH 12/25] feat: add /ping command to chat for latency measurement --- test_utils/wireworm_chat.go | 77 +++++++++++++++++++++++++++++++------ 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/test_utils/wireworm_chat.go b/test_utils/wireworm_chat.go index 9d23620..6f3434b 100644 --- a/test_utils/wireworm_chat.go +++ b/test_utils/wireworm_chat.go @@ -8,6 +8,16 @@ import ( "net" "os" "strings" + "time" +) + +const ( + ColorGreen = "\033[0;32m" + ColorBlue = "\033[0;34m" + ColorCyan = "\033[0;36m" + ColorYellow = "\033[1;33m" + ColorRed = "\033[0;31m" + ColorNC = "\033[0m" ) func main() { @@ -25,7 +35,7 @@ func main() { var err error if mode == "server" { - fmt.Printf("Chat Server listening on 127.0.0.1:%s...\n", target) + fmt.Printf(ColorCyan+"Chat Server listening on 127.0.0.1:%s..."+ColorNC+"\n", target) ln, err := net.Listen("tcp", "127.0.0.1:"+target) if err != nil { log.Fatal(err) @@ -34,34 +44,77 @@ func main() { if err != nil { log.Fatal(err) } - fmt.Println("Peer connected! Start typing (Ctrl+C to quit).") + fmt.Println(ColorGreen + "Peer connected! Start typing (type '/ping' for RTT, Ctrl+C to quit)." + ColorNC) } else { - fmt.Printf("Connecting to Chat Server at %s...\n", target) + fmt.Printf(ColorCyan+"Connecting to Chat Server at %s..."+ColorNC+"\n", target) conn, err = net.Dial("tcp", target) if err != nil { log.Fatal(err) } - fmt.Println("Connected to peer! Start typing (Ctrl+C to quit).") + fmt.Println(ColorGreen + "Connected to peer! Start typing (type '/ping' for RTT, Ctrl+C to quit)." + ColorNC) } defer conn.Close() - // 2-way communication + // Receiver loop go func() { - _, _ = io.Copy(os.Stdout, conn) - fmt.Println("\nPeer disconnected.") - os.Exit(0) + reader := bufio.NewReader(conn) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + fmt.Println("\n" + ColorYellow + "Peer disconnected." + ColorNC) + } else { + fmt.Printf("\n"+ColorRed+"Connection error: %v"+ColorNC+"\n", err) + } + os.Exit(0) + } + + line = strings.TrimSpace(line) + + // Internal protocol + if strings.HasPrefix(line, "PONG:") { + var ts int64 + fmt.Sscanf(line, "PONG:%d", &ts) + sentTime := time.Unix(0, ts) + rtt := time.Since(sentTime) + fmt.Printf("\r"+ColorGreen+"[LATENCY]"+ColorNC+" Round-trip time: %v\n", rtt) + fmt.Print("You: ") + continue + } + + if strings.HasPrefix(line, "PING:") { + _, _ = fmt.Fprintf(conn, "PONG:%s\n", line[5:]) + continue + } + + // Clean print for user + fmt.Printf("\r%s\n", line) + fmt.Print("You: ") + } }() + fmt.Print("You: ") scanner := bufio.NewScanner(os.Stdin) for scanner.Scan() { text := scanner.Text() - if strings.TrimSpace(text) == "" { + trimmed := strings.TrimSpace(text) + + if trimmed == "" { + fmt.Print("You: ") continue } - _, err := fmt.Fprintln(conn, "Peer: "+text) - if err != nil { - break + + if trimmed == "/ping" { + now := time.Now().UnixNano() + _, _ = fmt.Fprintf(conn, "PING:%d\n", now) + fmt.Printf(ColorBlue + "[INFO]" + ColorNC + " Pinging peer...\n") + } else { + _, err := fmt.Fprintf(conn, "Peer: %s\n", text) + if err != nil { + break + } } + fmt.Print("You: ") } } From 04d66cce02f2eabb49db821cfc39b96b08840d3a Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:40:48 -0600 Subject: [PATCH 13/25] feat: add Dockerfile for WireWorm and update documentation --- Dockerfile.wireworm | 47 +++++++++++++++++++++++++++++++++++++++++++++ WIREWORM.md | 13 +++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 Dockerfile.wireworm diff --git a/Dockerfile.wireworm b/Dockerfile.wireworm new file mode 100644 index 0000000..fd7a466 --- /dev/null +++ b/Dockerfile.wireworm @@ -0,0 +1,47 @@ +# Multi-stage build for a clean, efficient image +FROM docker.io/golang:1.21-bookworm AS build + +# Set the working directory +WORKDIR /usr/src/wireproxy + +# Copy everything for building +COPY . . + +# Build the wireproxy binary +RUN make + +# Start a fresh stage for the final image +FROM docker.io/golang:1.21-bookworm + +# Install runtime dependencies for WireWorm +# - wireguard-tools: for 'wg' keys +# - stuntman-client: for STUN/NAT discovery +# - bash/curl/make: for scripts and building +RUN apt-get update && apt-get install -y \ + wireguard-tools \ + stuntman-client \ + bash \ + curl \ + make \ + iproute2 \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Set up the application directory +WORKDIR /app + +# Copy the built wireproxy from the build stage +COPY --from=build /usr/src/wireproxy/wireproxy /app/wireproxy +# Copy the rest of the source (needed for 'go run' chat/sender tools) +COPY . /app/ + +# Ensure scripts are executable +RUN chmod +x /app/wireworm_interactive.sh /app/wireworm.sh + +# Metadata +LABEL org.opencontainers.image.title="WireWorm" +LABEL org.opencontainers.image.description="NAT Hole Punching PoC with userspace WireGuard" + +# WireWorm requires host networking for effective NAT hole punching +# Use: docker run -it --rm --network host wireworm +ENTRYPOINT ["/bin/bash", "./wireworm_interactive.sh"] diff --git a/WIREWORM.md b/WIREWORM.md index 66b0cf8..bbc9faf 100644 --- a/WIREWORM.md +++ b/WIREWORM.md @@ -29,6 +29,19 @@ cd wireproxy bash wireworm_interactive.sh ``` +### Usage (Docker) + +You can also run WireWorm in a container. Note that `--network host` is required for successful UDP hole punching to occur through the host's NAT. + +1. **Build the image**: + ```bash + docker build -t wireworm -f Dockerfile.wireworm . + ``` +2. **Run the container**: + ```bash + docker run -it --rm --network host wireworm + ``` + 1. **Select Mode**: Choose between File Transfer or Chat. 2. **Exchange Connection String**: The script will provide a single string (e.g., `IP:PORT:PUBKEY`) to share with your peer. 3. **Establish Secure Tunnel**: Once both peers enter each other's strings, the NAT hole is punched and the WireGuard handshake begins. From 9f665daf5c284caf7e1321ff939559f98b0dcbba Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:46:03 -0600 Subject: [PATCH 14/25] fix: build stuntman from source in Dockerfile --- Dockerfile.wireworm | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/Dockerfile.wireworm b/Dockerfile.wireworm index fd7a466..ea44831 100644 --- a/Dockerfile.wireworm +++ b/Dockerfile.wireworm @@ -1,38 +1,45 @@ # Multi-stage build for a clean, efficient image -FROM docker.io/golang:1.21-bookworm AS build - -# Set the working directory +FROM docker.io/golang:1.21-bookworm AS build-wireproxy WORKDIR /usr/src/wireproxy - -# Copy everything for building COPY . . +RUN make -# Build the wireproxy binary +# Build Stuntman from source in a dedicated stage +FROM docker.io/debian:bookworm AS build-stuntman +RUN apt-get update && apt-get install -y \ + g++ \ + make \ + libboost-dev \ + libssl-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/stuntman +RUN curl -L https://github.com/jselbie/stuntman/archive/refs/tags/v2.1.0.tar.gz | tar -xz --strip-components=1 RUN make -# Start a fresh stage for the final image +# Final image FROM docker.io/golang:1.21-bookworm -# Install runtime dependencies for WireWorm -# - wireguard-tools: for 'wg' keys -# - stuntman-client: for STUN/NAT discovery -# - bash/curl/make: for scripts and building +# Install runtime dependencies RUN apt-get update && apt-get install -y \ wireguard-tools \ - stuntman-client \ bash \ curl \ make \ iproute2 \ procps \ + libssl3 \ && rm -rf /var/lib/apt/lists/* -# Set up the application directory WORKDIR /app -# Copy the built wireproxy from the build stage -COPY --from=build /usr/src/wireproxy/wireproxy /app/wireproxy -# Copy the rest of the source (needed for 'go run' chat/sender tools) +# Copy binaries from build stages +COPY --from=build-wireproxy /usr/src/wireproxy/wireproxy /usr/bin/wireproxy +COPY --from=build-stuntman /usr/src/stuntman/stunclient /usr/bin/stunclient +COPY --from=build-stuntman /usr/src/stuntman/stunserver /usr/bin/stunserver + +# Copy source for 'go run' utilities COPY . /app/ # Ensure scripts are executable @@ -40,8 +47,7 @@ RUN chmod +x /app/wireworm_interactive.sh /app/wireworm.sh # Metadata LABEL org.opencontainers.image.title="WireWorm" -LABEL org.opencontainers.image.description="NAT Hole Punching PoC with userspace WireGuard" +LABEL org.opencontainers.image.description="NAT Hole Punching PoC with userspace WireGuard and built-in STUN client" -# WireWorm requires host networking for effective NAT hole punching -# Use: docker run -it --rm --network host wireworm +# Entrypoint setup ENTRYPOINT ["/bin/bash", "./wireworm_interactive.sh"] From c5616611c083fbb9ef451561c409f6fe8ea2c5bd Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:49:34 -0600 Subject: [PATCH 15/25] fix: resolve docker build error and monitor shell syntax --- Dockerfile.wireworm | 3 ++- wireworm_interactive.sh | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Dockerfile.wireworm b/Dockerfile.wireworm index ea44831..147a409 100644 --- a/Dockerfile.wireworm +++ b/Dockerfile.wireworm @@ -12,10 +12,11 @@ RUN apt-get update && apt-get install -y \ libboost-dev \ libssl-dev \ curl \ + ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/stuntman -RUN curl -L https://github.com/jselbie/stuntman/archive/refs/tags/v2.1.0.tar.gz | tar -xz --strip-components=1 +RUN curl -L https://github.com/jselbie/stunserver/archive/refs/heads/main.tar.gz | tar -xz --strip-components=1 RUN make # Final image diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index 4177c94..2166df2 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -224,7 +224,8 @@ if [[ "$SUB_MODE" == "file" ]]; then CONNECTED=false while kill -0 $WIREPROXY_PID 2>/dev/null; do METRICS=$(curl -s http://127.0.0.1:8081/metrics || echo "") - HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 | xargs || echo "0") + HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 | xargs) + HANDSHAKE=${HANDSHAKE:-0} if [[ ! "$HANDSHAKE" =~ ^[0-9]+$ ]]; then HANDSHAKE=0; fi if [ "$HANDSHAKE" -gt 0 ]; then @@ -258,7 +259,8 @@ else echo -e "${BLUE}Waiting for peer to connect...${NC}" while kill -0 $WIREPROXY_PID 2>/dev/null; do METRICS=$(curl -s http://127.0.0.1:8081/metrics || echo "") - HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 | xargs || echo "0") + HANDSHAKE=$(echo "$METRICS" | grep "last_handshake_time_sec" | cut -d'=' -f2 | xargs) + HANDSHAKE=${HANDSHAKE:-0} if [[ ! "$HANDSHAKE" =~ ^[0-9]+$ ]]; then HANDSHAKE=0; fi if [ "$HANDSHAKE" -gt 0 ]; then From 75f135e13a69a4c4a142d1882005c37354c2a54e Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:50:39 -0600 Subject: [PATCH 16/25] fix: use git clone for stuntman source to fix docker build --- Dockerfile.wireworm | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile.wireworm b/Dockerfile.wireworm index 147a409..71ac27f 100644 --- a/Dockerfile.wireworm +++ b/Dockerfile.wireworm @@ -11,13 +11,12 @@ RUN apt-get update && apt-get install -y \ make \ libboost-dev \ libssl-dev \ - curl \ + git \ ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/stuntman -RUN curl -L https://github.com/jselbie/stunserver/archive/refs/heads/main.tar.gz | tar -xz --strip-components=1 -RUN make +RUN git clone https://github.com/jselbie/stunserver.git . && make # Final image FROM docker.io/golang:1.21-bookworm From 2491bfd710b1e1de7c48e72177ff8b2b313f908a Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 15:59:17 -0600 Subject: [PATCH 17/25] fix: restore mode selection logic in interactive script --- wireworm_interactive.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index 2166df2..a18c483 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -279,6 +279,14 @@ else sleep 2 done + # Check if we exited because wireproxy died + if ! kill -0 $WIREPROXY_PID 2>/dev/null; then + echo -e "\n${RED}Error: wireproxy process died unexpectedly!${NC}" + echo -e "${YELLOW}--- Last logs from wireproxy.log ---${NC}" + tail -n 20 wireproxy.log + exit 1 + fi + # Cleanup and exit cleanly kill $WIREPROXY_PID 2>/dev/null || true exit 0 From 338f664753fd26eb44d26ff6146eeac106eaca2a Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 16:02:30 -0600 Subject: [PATCH 18/25] feat: improve error diagnostics and log reporting in interactive script --- wireworm_interactive.sh | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index a18c483..1d79d4b 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -135,7 +135,12 @@ echo -e "${GREEN}---------------------------------------------------${NC}\n" done ) & MAINTAINER_PID=$! -trap 'kill $MAINTAINER_PID 2>/dev/null || true' EXIT +cleanup() { + # Disable the trap to prevent recursion + trap - INT TERM EXIT + kill $WIREPROXY_PID $SERVER_PID $MAINTAINER_PID $MONITOR_PID 2>/dev/null || true +} +trap cleanup INT TERM EXIT # 4. Input Peer Data echo -e "${BLUE}Enter Peer Information:${NC}" @@ -215,7 +220,7 @@ fi WIREPROXY_PID=$! # Handle shutdown -trap 'kill $WIREPROXY_PID $SERVER_PID $MAINTAINER_PID $MONITOR_PID 2>/dev/null || true; exit' INT TERM EXIT +# (Cleanup is now handled by the 'cleanup' function above) # Handshake Monitor Loop if [[ "$SUB_MODE" == "file" ]]; then @@ -279,15 +284,20 @@ else sleep 2 done - # Check if we exited because wireproxy died + # If we are here, something went wrong or the loop finished without break if ! kill -0 $WIREPROXY_PID 2>/dev/null; then echo -e "\n${RED}Error: wireproxy process died unexpectedly!${NC}" echo -e "${YELLOW}--- Last logs from wireproxy.log ---${NC}" - tail -n 20 wireproxy.log + if [ -f wireproxy.log ]; then + tail -n 30 wireproxy.log + else + echo "Log file not found." + fi + cleanup exit 1 fi # Cleanup and exit cleanly - kill $WIREPROXY_PID 2>/dev/null || true + cleanup exit 0 fi From 2680a236dc2f2642590aeb36e21a67cb25b0d65f Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 16:04:38 -0600 Subject: [PATCH 19/25] fix: resolve Exec format error by excluding host binaries from image --- .dockerignore | 6 ++++++ Dockerfile.wireworm | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index fc3c142..79c87b9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,11 @@ .github .gitignore Dockerfile +Dockerfile.wireworm LICENSE README.md +wireproxy +wireworm.conf +wireproxy.log +*.txt +*.bin diff --git a/Dockerfile.wireworm b/Dockerfile.wireworm index 71ac27f..ca21db7 100644 --- a/Dockerfile.wireworm +++ b/Dockerfile.wireworm @@ -35,12 +35,16 @@ RUN apt-get update && apt-get install -y \ WORKDIR /app # Copy binaries from build stages -COPY --from=build-wireproxy /usr/src/wireproxy/wireproxy /usr/bin/wireproxy +# Placing wireproxy in /app ensures the bash script finds the correct Linux binary +COPY --from=build-wireproxy /usr/src/wireproxy/wireproxy /app/wireproxy COPY --from=build-stuntman /usr/src/stuntman/stunclient /usr/bin/stunclient COPY --from=build-stuntman /usr/src/stuntman/stunserver /usr/bin/stunserver # Copy source for 'go run' utilities +# Use a specific list or ensure we don't overwrite the correctly built binary COPY . /app/ +# Re-copy the correct binary JUST in case the 'COPY . /app/' overwrote it with a host binary +COPY --from=build-wireproxy /usr/src/wireproxy/wireproxy /app/wireproxy # Ensure scripts are executable RUN chmod +x /app/wireworm_interactive.sh /app/wireworm.sh From 26bcf15f1e86df843a46619e308ba7ea9e96a024 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 16:07:47 -0600 Subject: [PATCH 20/25] feat: support fixed UDP port for Docker on Mac/Windows --- WIREWORM.md | 27 +++++++++++++++++---------- wireworm_interactive.sh | 8 ++++++-- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/WIREWORM.md b/WIREWORM.md index bbc9faf..2ded1e6 100644 --- a/WIREWORM.md +++ b/WIREWORM.md @@ -31,16 +31,23 @@ bash wireworm_interactive.sh ### Usage (Docker) -You can also run WireWorm in a container. Note that `--network host` is required for successful UDP hole punching to occur through the host's NAT. - -1. **Build the image**: - ```bash - docker build -t wireworm -f Dockerfile.wireworm . - ``` -2. **Run the container**: - ```bash - docker run -it --rm --network host wireworm - ``` +You can also run WireWorm in a container. + +#### On Linux (Native Docker): +```bash +docker run -it --rm --network host wireworm +``` + +#### On macOS / Windows (Docker Desktop): +On Mac and Windows, Docker runs inside a virtual machine, so `--network host` doesn't provide direct access to your Mac's network. You must use explicit port mapping: + +```bash +docker run -it --rm \ + -e WIRE_PORT=51820 \ + -p 51820:51820/udp \ + wireworm +``` +*Note: This maps the hole-punching port explicitly so it can reach the container.* 1. **Select Mode**: Choose between File Transfer or Chat. 2. **Exchange Connection String**: The script will provide a single string (e.g., `IP:PORT:PUBKEY`) to share with your peer. diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index 1d79d4b..c8ac908 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -40,8 +40,12 @@ PRIV=$(wg genkey) PUB=$(echo "$PRIV" | wg pubkey) # --- NAT Discovery --- -# Use a random local port for better punch-through and to avoid conflicts -LOCAL_WG_PORT=$((RANDOM % 55535 + 10000)) +# Use a random local port by default, or an environment variable if provided +if [[ -z "$WIRE_PORT" ]]; then + LOCAL_WG_PORT=$((RANDOM % 55535 + 10000)) +else + LOCAL_WG_PORT=$WIRE_PORT +fi echo -e "${BLUE}Discovering NAT mapping using local port $LOCAL_WG_PORT...${NC}" # Try multiple servers if one is down From 75ac159fc398b10a7b381fed6d63fe161e65661a Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 16:31:31 -0600 Subject: [PATCH 21/25] fix: cross-platform timeout for stun discovery on macOS --- wireworm_interactive.sh | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index c8ac908..c994de5 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -47,25 +47,41 @@ else LOCAL_WG_PORT=$WIRE_PORT fi -echo -e "${BLUE}Discovering NAT mapping using local port $LOCAL_WG_PORT...${NC}" +echo -ne "${BLUE}Discovering NAT mapping... ${NC}" # Try multiple servers if one is down STUN_SERVERS=("stun.l.google.com:19302" "stunserver.org:3478" "stun.voip.blackberry.com:3478") STUN_OUT="" +STUN_ERRORS="" for s in "${STUN_SERVERS[@]}"; do server=$(echo $s | cut -d':' -f1) port=$(echo $s | cut -d':' -f2) - STUN_OUT=$(stunclient --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "") - if [[ "$STUN_OUT" == *"Mapped address"* ]]; then + echo -ne "${YELLOW}.${NC}" + + # Use timeout only if available (GNU coreutils, usually missing on macOS) + if command -v timeout &> /dev/null; then + TMP_OUT=$(timeout 5 stunclient --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "Error: Timeout") + else + # Fallback for macOS: no timeout (relying on stunclient internal timeout) + TMP_OUT=$(stunclient --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "Error: stunclient failed") + fi + + if [[ "$TMP_OUT" == *"Mapped address"* ]]; then + STUN_OUT="$TMP_OUT" break + else + STUN_ERRORS+="\n - $s: $(echo "$TMP_OUT" | head -n 1)" fi done +echo -e " ${GREEN}Done!${NC}" PUB_IP=$(echo "$STUN_OUT" | grep -oE "Mapped address: [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+" | cut -d' ' -f3 | cut -d':' -f1 || echo "") PUB_PORT=$(echo "$STUN_OUT" | grep -oE "Mapped address: [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+" | cut -d' ' -f3 | cut -d':' -f2 || echo "") if [[ -z "$PUB_IP" || -z "$PUB_PORT" ]]; then - echo -e "${YELLOW}Warning: STUN discovery failed. Falling back to simple IP discovery.${NC}" + echo -e "${YELLOW}Warning: STUN discovery failed.${NC}" + echo -e "${RED}Detailed Errors:${NC}${STUN_ERRORS}" + echo -e "${YELLOW}Falling back to simple IP discovery.${NC}" PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") PUB_PORT=$LOCAL_WG_PORT fi @@ -130,15 +146,14 @@ echo -e "${YELLOW}CONNECTION:${NC} ${CYAN}$PUB_IP:$PUB_PORT:$PUB${NC}" echo -e "${GREEN}---------------------------------------------------${NC}\n" # Start a background "Hole Maintainer" to keep the NAT mapping from expiring -# while the user is typing the peer information. ( while true; do - # Sending a tiny packet to the stun server to keep the mapping active stunclient --localport $LOCAL_WG_PORT stun.l.google.com 19302 &> /dev/null sleep 20 done -) & +) &>/dev/null & MAINTAINER_PID=$! +disown $MAINTAINER_PID 2>/dev/null || true cleanup() { # Disable the trap to prevent recursion trap - INT TERM EXIT From f6568b1a664bf71bb32d26cf25e45378d77c2d94 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 16:38:56 -0600 Subject: [PATCH 22/25] perf: optimize stun discovery with basic mode and faster timeouts --- WIREWORM.md | 6 +++++- wireworm_interactive.sh | 20 +++++++------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/WIREWORM.md b/WIREWORM.md index 2ded1e6..badc094 100644 --- a/WIREWORM.md +++ b/WIREWORM.md @@ -47,7 +47,11 @@ docker run -it --rm \ -p 51820:51820/udp \ wireworm ``` -*Note: This maps the hole-punching port explicitly so it can reach the container.* + +**Why this is necessary:** +- **The Chain**: `Internet (Public Port)` $\to$ `Router` $\to$ `Mac/Win (Host Port)` $\to$ `Docker VM` $\to$ `Container (Local Port)`. +- Without `-p`, your Mac doesn't know to forward incoming P2P packets from the internet into the Docker VM. +- **WIRE_PORT** ensures the script inside Docker actually uses the port you've opened on your host. 1. **Select Mode**: Choose between File Transfer or Chat. 2. **Exchange Connection String**: The script will provide a single string (e.g., `IP:PORT:PUBKEY`) to share with your peer. diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index c994de5..d1b4fe2 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -51,26 +51,22 @@ echo -ne "${BLUE}Discovering NAT mapping... ${NC}" # Try multiple servers if one is down STUN_SERVERS=("stun.l.google.com:19302" "stunserver.org:3478" "stun.voip.blackberry.com:3478") STUN_OUT="" -STUN_ERRORS="" for s in "${STUN_SERVERS[@]}"; do server=$(echo $s | cut -d':' -f1) port=$(echo $s | cut -d':' -f2) echo -ne "${YELLOW}.${NC}" - # Use timeout only if available (GNU coreutils, usually missing on macOS) + # Use native timeout if available, otherwise just run + # '--mode basic' is much faster and avoids Docker network driver bottlenecks if command -v timeout &> /dev/null; then - TMP_OUT=$(timeout 5 stunclient --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "Error: Timeout") + STUN_OUT=$(timeout 2 stunclient --mode basic --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "") else - # Fallback for macOS: no timeout (relying on stunclient internal timeout) - TMP_OUT=$(stunclient --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "Error: stunclient failed") + STUN_OUT=$(stunclient --mode basic --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "") fi - - if [[ "$TMP_OUT" == *"Mapped address"* ]]; then - STUN_OUT="$TMP_OUT" + + if [[ "$STUN_OUT" == *"Mapped address"* ]]; then break - else - STUN_ERRORS+="\n - $s: $(echo "$TMP_OUT" | head -n 1)" fi done echo -e " ${GREEN}Done!${NC}" @@ -79,9 +75,7 @@ PUB_IP=$(echo "$STUN_OUT" | grep -oE "Mapped address: [0-9]+\.[0-9]+\.[0-9]+\.[0 PUB_PORT=$(echo "$STUN_OUT" | grep -oE "Mapped address: [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+" | cut -d' ' -f3 | cut -d':' -f2 || echo "") if [[ -z "$PUB_IP" || -z "$PUB_PORT" ]]; then - echo -e "${YELLOW}Warning: STUN discovery failed.${NC}" - echo -e "${RED}Detailed Errors:${NC}${STUN_ERRORS}" - echo -e "${YELLOW}Falling back to simple IP discovery.${NC}" + echo -e "${YELLOW}Warning: STUN discovery failed. Falling back to simple IP discovery.${NC}" PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") PUB_PORT=$LOCAL_WG_PORT fi From 96feb9512adbdf63d0bb3fdde211c0baa257289a Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 16:52:22 -0600 Subject: [PATCH 23/25] fix(wireworm): improve STUN reliability with IPv4 forcing, debug logs, and increased timeouts --- wireworm_interactive.sh | 66 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/wireworm_interactive.sh b/wireworm_interactive.sh index d1b4fe2..1d03656 100755 --- a/wireworm_interactive.sh +++ b/wireworm_interactive.sh @@ -47,7 +47,7 @@ else LOCAL_WG_PORT=$WIRE_PORT fi -echo -ne "${BLUE}Discovering NAT mapping... ${NC}" +echo -ne "${BLUE}Discovering NAT mapping... ${NC}\n" # Try multiple servers if one is down STUN_SERVERS=("stun.l.google.com:19302" "stunserver.org:3478" "stun.voip.blackberry.com:3478") STUN_OUT="" @@ -55,18 +55,68 @@ STUN_OUT="" for s in "${STUN_SERVERS[@]}"; do server=$(echo $s | cut -d':' -f1) port=$(echo $s | cut -d':' -f2) - echo -ne "${YELLOW}.${NC}" + echo -e "${YELLOW}Trying $server:$port...${NC}" + + # Helper for current time in ms + current_time_ms() { + if command -v python3 &>/dev/null; then + python3 -c 'import time; print(int(time.time() * 1000))' + else + echo $(($(date +%s) * 1000)) + fi + } + + # 1. DNS Resolution Timing (Force IPv4) + echo -ne " DNS Resolve: " + START_DNS=$(current_time_ms) - # Use native timeout if available, otherwise just run - # '--mode basic' is much faster and avoids Docker network driver bottlenecks - if command -v timeout &> /dev/null; then - STUN_OUT=$(timeout 2 stunclient --mode basic --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "") + # Portable DNS resolution (Python is best common denominator usually) + if command -v python3 &>/dev/null; then + RESOLVED_IP=$(python3 -c "import socket; print(socket.gethostbyname('$server'))" 2>/dev/null || echo "") else - STUN_OUT=$(stunclient --mode basic --localport $LOCAL_WG_PORT $server $port 2>&1 || echo "") + # Fallback for systems without python3 (rare but possible in minimal containers) + if command -v getent &>/dev/null; then + RESOLVED_IP=$(getent hosts "$server" | awk '{ print $1 }' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1) + else + # Very basic fallback for macOS if no python + RESOLVED_IP=$(ping -c 1 $server 2>/dev/null | head -n 1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -n 1) + fi fi - if [[ "$STUN_OUT" == *"Mapped address"* ]]; then + END_DNS=$(current_time_ms) + DNS_DUR=$(( END_DNS - START_DNS )) + + if [[ -z "$RESOLVED_IP" ]]; then + echo -e "${RED}FAILED${NC} (No IPv4 found, ${DNS_DUR}ms)" + continue + else + echo -e "${GREEN}OK${NC} ($RESOLVED_IP, ${DNS_DUR}ms)" + fi + + # 2. STUN Request Timing + echo -ne " STUN Request: " + START_STUN=$(current_time_ms) + + # Increase timeout to 5s + # Pass the RESOLVED_IP to stunclient to avoid re-resolution or IPv6 usage + CMD_OUT="" + if command -v timeout &> /dev/null; then + CMD_OUT=$(timeout 5 stunclient --mode basic --localport $LOCAL_WG_PORT $RESOLVED_IP $port 2>&1 || echo "TIMEOUT") + elif command -v gtimeout &> /dev/null; then + # macOS coreutils support + CMD_OUT=$(gtimeout 5 stunclient --mode basic --localport $LOCAL_WG_PORT $RESOLVED_IP $port 2>&1 || echo "TIMEOUT") + else + CMD_OUT=$(stunclient --mode basic --localport $LOCAL_WG_PORT $RESOLVED_IP $port 2>&1 || echo "FAILED") + fi + END_STUN=$(current_time_ms) + STUN_DUR=$(( END_STUN - START_STUN )) + + if [[ "$CMD_OUT" == *"Mapped address"* ]]; then + echo -e "${GREEN}SUCCESS${NC} (${STUN_DUR}ms)" + STUN_OUT="$CMD_OUT" break + else + echo -e "${RED}FAILED${NC} (${STUN_DUR}ms) - Output: ${CMD_OUT}" fi done echo -e " ${GREEN}Done!${NC}" From e7fe66d8c10a3c2e354079241c177887964054a5 Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Thu, 15 Jan 2026 17:03:19 -0600 Subject: [PATCH 24/25] docs: add analysis of Docker Desktop NAT limitations --- DOCKER_NAT_ANALYSIS.md | 56 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 DOCKER_NAT_ANALYSIS.md diff --git a/DOCKER_NAT_ANALYSIS.md b/DOCKER_NAT_ANALYSIS.md new file mode 100644 index 0000000..a42a95d --- /dev/null +++ b/DOCKER_NAT_ANALYSIS.md @@ -0,0 +1,56 @@ +# Docker NAT Hole Punching Analysis + +## The Issue: "Symmetric NAT" in Docker Desktop + +We successfully hole-punched natively on macOS, but failed consistently when running the same logic inside a Docker container on Docker Desktop (Mac/Windows). + +### Root Cause Analysis + +UDP Hole Punching relies on a critical assumption: **Cone NAT**. +> The router must map `InternalIP:Port` to the **same** `ExternalIP:Port` regardless of the destination address. + +1. **Native Execution**: macOS Kernel -> Router (Cone NAT) -> Internet. + * Request to STUN Server -> Source Port `51820` preserved (or mapped consistently). + * Request to Peer -> Source Port `51820` preserved. + * **Result**: Success. + +2. **Docker Desktop Execution**: Container -> **Linux VM Bridge** -> **VPNKit Userland Proxy** -> Host OS -> Router -> Internet. + * This translation layer often behaves as **Symmetric NAT**. + * Request to STUN Server: Mapped to External Port `32001`. + * Request to Peer: Mapped to External Port `45002`. + * **Result**: The peer tries to reply to `32001` (what STUN saw), but your firewall expects traffic on `45002`. Packet dropped. + +## Proposed Solutions + +To successfully containerize this utility, we must bypass the Docker Desktop networking abstraction. + +### 1. Host Networking (Linux Only) +On native Linux, `--network host` shares the host's networking stack directly. +* **Feasibility**: High (Linux), Zero (Mac/Windows). +* **Command**: `docker run --network host ...` +* **Limitation**: On Docker Desktop, this only shares the *Linux VM's* network, not the Mac/Windows Host network, so it remains double-natted. + +### 2. Macvlan Network Driver +Giving the container its own IP address on the local LAN, bypassing the host's NAT entirely. +* **Feasibility**: Medium. Requires network configuration access. +* **Command**: + ```bash + docker network create -d macvlan --subnet=192.168.1.0/24 --gateway=192.168.1.1 -o parent=en0 pub_net + docker run --net pub_net ... + ``` +* **Pros**: Makes the container appear as a physical device on the network. +* **Cons**: Wireless adapters (WiFi) often reject macvlan traffic due to security features (only one MAC address allowed per client). + +### 3. UDP Port Preservation (High Difficulty) +We need a way to force Docker's outbound NAT to preserve the source port. +* **Method**: Utilize `iptables` inside the Docker VM (if accessible) to set SNAT rules. +* **Complexity**: Docker Desktop does not easily allow modifying the VM's `iptables`. + +### 4. Hybrid Approach (The "Sidecar") +Run the `wireproxy` logic in the container, but run the networking/socket layer on the host. +* **Method**: This effectively defeats the purpose of containerization (portability). + +## Conclusion +For **UDP Hole Punching**, the physical network layer is leaking into the abstraction. Docker Desktop's default networking mode is fundamentally incompatible with the requirement of "Endpoint-Independent Mapping" needed for P2P connection establishment without a relay. + +**Recommendation**: Detect the environment. If Docker is detected, warn the user that hole punching may fail unless they are on native Linux or using advanced network drivers (Macvlan). From 4b1176e0606d0920b7bb57914c78d58a3edb86ab Mon Sep 17 00:00:00 2001 From: Mo Balaa Date: Fri, 16 Jan 2026 11:09:48 -0600 Subject: [PATCH 25/25] feat: add NAT hole punching for peer-to-peer WireGuard tunnels - Add STUN client (stun.go) for NAT discovery using pion/stun - Add hole punch session management (holepunch.go) with wormhole codes - Add simple rendezvous server (cmd/rendezvous) for connection exchange - Add CLI flags: --holepunch, --expose, --code, --local - Add fallback to manual connection string exchange - Document usage and testing in HOLEPUNCH.md - Add Windows P2P DNS spec (GO_WINDOWS_DNS.md) --- GO_WINDOWS_DNS.md | 92 +++++++++++++++ HOLEPUNCH.md | 92 +++++++++++++++ cmd/rendezvous/main.go | 130 +++++++++++++++++++++ cmd/wireproxy/main.go | 170 ++++++++++++++++++++++++--- go.mod | 12 +- go.sum | 76 +++++++++++++ holepunch.go | 253 +++++++++++++++++++++++++++++++++++++++++ stun.go | 159 ++++++++++++++++++++++++++ 8 files changed, 967 insertions(+), 17 deletions(-) create mode 100644 GO_WINDOWS_DNS.md create mode 100644 HOLEPUNCH.md create mode 100644 cmd/rendezvous/main.go create mode 100644 holepunch.go create mode 100644 stun.go diff --git a/GO_WINDOWS_DNS.md b/GO_WINDOWS_DNS.md new file mode 100644 index 0000000..d6dbe5b --- /dev/null +++ b/GO_WINDOWS_DNS.md @@ -0,0 +1,92 @@ +# Specification: Go Windows P2P DNS Library (winp2pns) + +## 1. Core Objectives +* **Namespace Scoping:** Intercept ONLY developer-defined domains (e.g., `*.p2p.local`) to ensure zero interference with the user's other web traffic. +* **Port Transparency:** Use DNS SRV records to map a friendly domain name to a dynamic local proxy port, removing the need for users to type ports in the game client. +* **Seamless Failover:** Implement a 0-TTL (Time-to-Live) policy to allow the utility to switch between P2P and Relay IPs/Ports instantly. +* **System Integration:** Utilize the Windows Name Resolution Policy Table (NRPT) for "Split-Horizon" DNS without modifying global network adapter settings. + +--- + +## 2. Component Architecture + +### 2.1 NRPT Manager (Registry Integration) +The library manipulates the Windows NRPT to tell the OS: *"If a query ends in .p2p.local, ask the DNS server at 127.0.0.1; otherwise, use the ISP."* + +**Registry Path:** +`HKLM\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig\{GUID}` + +| Value Name | Type | Value / Purpose | +| :--- | :--- | :--- | +| `Name` | REG_MULTI_SZ | The namespace suffix (e.g., `.p2p.local`) | +| `GenericDNSServers` | REG_SZ | `127.0.0.1` | +| `ConfigOptions` | REG_DWORD | `1` (Enables the rule) | + +### 2.2 DNS Responder (UDP 53) +A lightweight DNS server implemented using the `github.com/miekg/dns` package. + +* **Listener:** Binds to `127.0.0.1:53` (UDP). +* **Authoritative Flag:** All responses must have the `Authoritative` bit set to true. +* **Caching Policy:** All Resource Records (RRs) must have a `TTL` of `0`. + +### 2.3 State Provider (Interface) +The library consumes an interface to retrieve real-time tunnel information. + +```go +type ProxyState struct { + LocalPort uint16 // The local port the proxy is currently listening on + IsActive bool // If false, the DNS server returns RCODE_NAME_ERROR or RCODE_REFUSED +} + +type TunnelProvider interface { + GetProxyState(hostname string) (ProxyState, error) +} +``` + +--- + +## 3. Technical Workflow + +### 3.1 Initialization Sequence +1. **Privilege Validation:** Check if the process has Administrative/System privileges. +2. **Namespace Registration:** Generate a unique GUID and create the NRPT registry subkey. +3. **Socket Binding:** Attempt to bind to UDP port 53 on `127.0.0.1`. + * *Fallback:* If port 53 is blocked, notify the user or attempt to bind to `127.0.0.2`. +4. **Service Start:** Start the `dns.Server` loop. + +### 3.2 DNS Query Resolution Logic +When a DNS query is received: + +1. **Suffix Validation:** If the query does not match the registered namespace, ignore or return `RCODE_REFUSED`. +2. **Minecraft SRV Handling:** + * **Query:** `_minecraft._tcp.[server].p2p.local` (Type: SRV) + * **Response (Answer):** `SRV 0 0 [ProxyState.LocalPort] tunnel.[server].p2p.local` + * **Response (Additional):** `tunnel.[server].p2p.local A 127.0.0.1` +3. **Standard A-Record Handling:** + * **Query:** `[any].p2p.local` (Type: A) + * **Response:** `A 127.0.0.1` + + + +### 3.3 Cleanup Sequence +On application exit (Signal or Graceful): +1. Stop the DNS listener loop. +2. Delete the specific GUID subkey from `HKLM\...\DnsPolicyConfig`. +3. Flush the Windows DNS cache via `dnscache` service or `ipconfig /flushdns` command. + +--- + +## 4. Proposed Go Package Structure + +```text +winp2pns/ +ā”œā”€ā”€ nrpt_windows.go # NRPT logic using golang.org/x/sys/windows/registry +ā”œā”€ā”€ dns_handler.go # UDP server logic using github.com/miekg/dns +ā”œā”€ā”€ provider.go # Interface and state struct definitions +└── privilege.go # Manifest/Token checks for Admin rights +``` + +## 5. Implementation Constraints +* **Conflict Management:** The library must handle cases where the NRPT registry key already exists from a previous crash by overwriting it with the new local configuration. +* **Response Latency:** Since Minecraft checks DNS before connecting, the `TunnelProvider.GetProxyState` call must be non-blocking or extremely low-latency. +* **Bedrock Support:** For Bedrock edition, if SRV records are ignored, the utility should ideally attempt to use the default port `19132` locally to ensure a "port-less" experience. \ No newline at end of file diff --git a/HOLEPUNCH.md b/HOLEPUNCH.md new file mode 100644 index 0000000..200e513 --- /dev/null +++ b/HOLEPUNCH.md @@ -0,0 +1,92 @@ +# NAT Hole Punching for WireProxy + +This feature adds NAT hole punching capability to wireproxy, enabling peer-to-peer WireGuard tunnel establishment without manual IP/port configuration. + +## Quick Start + +### Host (expose a service like Minecraft) +```bash +./wireproxy --holepunch --expose 25565 +``` + +You'll see: +``` +šŸ” Discovering NAT mapping... +āœ“ NAT discovered: 203.0.113.5:51820 + +═══════════════════════════════════ + Share this code with your peer: + 93-raven-honey +═══════════════════════════════════ +``` + +### Joiner (connect to friend's service) +```bash +./wireproxy --holepunch --code 93-raven-honey --local 25565 +``` + +Then connect to `localhost:25565` to reach your friend's server. + +--- + +## Manual Testing Steps + +### Prerequisites +```bash +go build ./cmd/wireproxy +go build ./cmd/rendezvous +``` + +### Test 1: STUN Discovery +```bash +./wireproxy --holepunch --expose 25565 +# Should show your public IP and a wormhole code +# Press Ctrl+C to exit +``` + +### Test 2: Full Exchange (Local) + +**Terminal 1 - Rendezvous Server:** +```bash +./rendezvous +``` + +**Terminal 2 - Host:** +```bash +./wireproxy --holepunch --expose 8080 +# Note the code shown (e.g., "42-banana-sunset") +``` + +**Terminal 3 - Joiner:** +```bash +./wireproxy --holepunch --code 42-banana-sunset --local 8080 +``` + +### Test 3: Manual Fallback (No Server) + +If rendezvous is unavailable, the tool falls back to manual mode: +``` +āš ļø Rendezvous server unavailable. Using manual exchange. + +Your connection string: + hp://ABC123...@203.0.113.5:51820 + +Paste peer's connection string: _ +``` + +--- + +## CLI Reference + +| Flag | Description | +|------|-------------| +| `--holepunch` | Enable NAT hole punching mode | +| `--expose ` | Host mode: expose this local port | +| `--code ` | Join mode: peer's wormhole code | +| `--local ` | Join mode: local port to bind | + +--- + +## Known Limitations + +āš ļø **Docker Desktop**: NAT hole punching does NOT work inside Docker containers on Mac/Windows due to VPNKit/gVisor symmetric NAT. Run wireproxy natively on the host instead. diff --git a/cmd/rendezvous/main.go b/cmd/rendezvous/main.go new file mode 100644 index 0000000..dcc7474 --- /dev/null +++ b/cmd/rendezvous/main.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "sync" + "time" +) + +// Session represents a pending connection session +type Session struct { + PubKey string `json:"pubkey"` + Endpoint string `json:"endpoint"` + TunnelIP string `json:"tunnel_ip"` + Created time.Time `json:"-"` +} + +// Store holds pending sessions +type Store struct { + mu sync.RWMutex + sessions map[string]*Session // code -> session +} + +func NewStore() *Store { + s := &Store{ + sessions: make(map[string]*Session), + } + // Cleanup old sessions every minute + go func() { + ticker := time.NewTicker(time.Minute) + for range ticker.C { + s.cleanup() + } + }() + return s +} + +func (s *Store) cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for code, session := range s.sessions { + if now.Sub(session.Created) > 5*time.Minute { + delete(s.sessions, code) + } + } +} + +func (s *Store) Get(code string) *Session { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sessions[code] +} + +func (s *Store) Set(code string, session *Session) { + s.mu.Lock() + defer s.mu.Unlock() + session.Created = time.Now() + s.sessions[code] = session +} + +func (s *Store) Delete(code string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, code) +} + +type SessionRequest struct { + Code string `json:"code"` + PubKey string `json:"pubkey"` + Endpoint string `json:"endpoint"` + TunnelIP string `json:"tunnel_ip"` +} + +func main() { + store := NewStore() + + http.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + + var req SessionRequest + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if req.Code == "" { + http.Error(w, "Code is required", http.StatusBadRequest) + return + } + + // Check if peer already registered + existingPeer := store.Get(req.Code) + if existingPeer != nil { + // Peer exists, return their info and delete the session + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(existingPeer) + store.Delete(req.Code) + return + } + + // No peer yet, store our info and wait + store.Set(req.Code, &Session{ + PubKey: req.PubKey, + Endpoint: req.Endpoint, + TunnelIP: req.TunnelIP, + }) + + // Return 202 Accepted - peer hasn't connected yet + w.WriteHeader(http.StatusAccepted) + w.Write([]byte(`{"status": "waiting"}`)) + }) + + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + println("Rendezvous server listening on :8080") + http.ListenAndServe(":8080", nil) +} diff --git a/cmd/wireproxy/main.go b/cmd/wireproxy/main.go index 713943a..c690c0e 100644 --- a/cmd/wireproxy/main.go +++ b/cmd/wireproxy/main.go @@ -1,9 +1,9 @@ package main import ( + "bufio" "context" "fmt" - "github.com/landlock-lsm/go-landlock/landlock" "log" "net" "net/http" @@ -11,7 +11,11 @@ import ( "os/exec" "os/signal" "strconv" + "strings" "syscall" + "time" + + "github.com/landlock-lsm/go-landlock/landlock" "github.com/akamensky/argparse" "github.com/pufferffish/wireproxy" @@ -23,9 +27,9 @@ import ( const daemonProcess = "daemon-process" // default paths for wireproxy config file -var default_config_paths = []string { - "/etc/wireproxy/wireproxy.conf", - os.Getenv("HOME")+"/.config/wireproxy.conf", +var default_config_paths = []string{ + "/etc/wireproxy/wireproxy.conf", + os.Getenv("HOME") + "/.config/wireproxy.conf", } var version = "1.0.8-dev" @@ -59,12 +63,12 @@ func executablePath() string { // check if default config file paths exist func configFilePath() (string, bool) { - for _, path := range default_config_paths { - if _, err := os.Stat(path); err == nil { - return path, true - } - } - return "", false + for _, path := range default_config_paths { + if _, err := os.Stat(path); err == nil { + return path, true + } + } + return "", false } func lock(stage string) { @@ -152,6 +156,127 @@ func lockNetwork(sections []wireproxy.RoutineSpawner, infoAddr *string) { panicIfError(landlock.V4.BestEffort().RestrictNet(rules...)) } +// runHolePunch handles the NAT hole punching mode +func runHolePunch(exposePort int, peerCode string, localPort int, silent bool) { + // Determine mode + mode := "host" + if peerCode != "" { + mode = "join" + } + + if mode == "host" && exposePort == 0 { + fmt.Println("Error: --expose is required in host mode") + fmt.Println("Example: wireproxy --holepunch --expose 25565") + return + } + + if mode == "join" && localPort == 0 { + fmt.Println("Error: --local is required in join mode") + fmt.Println("Example: wireproxy --holepunch --code 7-pizza-elephant --local 25565") + return + } + + // Create hole punch session + config := &wireproxy.HolePunchConfig{ + Mode: mode, + LocalPort: 0, // Let OS pick + ExposePort: exposePort, + BindPort: localPort, + Code: peerCode, + } + + fmt.Println("šŸ” Discovering NAT mapping...") + session, err := wireproxy.NewHolePunchSession(config) + if err != nil { + log.Fatalf("Failed to create session: %v", err) + } + + fmt.Printf("āœ“ NAT discovered: %s\n\n", session.NATInfo.String()) + + if mode == "host" { + fmt.Println("═══════════════════════════════════") + fmt.Printf(" Share this code with your peer:\n") + fmt.Printf(" \033[1;32m%s\033[0m\n", session.Code) + fmt.Println("═══════════════════════════════════") + fmt.Println("\nWaiting for peer to connect...") + } else { + fmt.Printf("Connecting with code: %s\n", session.Code) + } + + // Start NAT keepalive + stopKeepalive := wireproxy.MaintainNATMapping(session.NATInfo.LocalPort, 20*time.Second) + defer stopKeepalive() + + // Exchange connection info via rendezvous + fmt.Println("šŸ“” Exchanging connection info...") + err = session.ExchangeViaRendezvous() + if err != nil { + // Fallback to manual mode + fmt.Printf("\nāš ļø Rendezvous server unavailable. Using manual exchange.\n\n") + fmt.Println("Your connection string:") + fmt.Printf(" \033[1;36m%s\033[0m\n\n", session.GetConnectionString()) + fmt.Print("Paste peer's connection string: ") + + reader := bufio.NewReader(os.Stdin) + peerConnStr, _ := reader.ReadString('\n') + peerConnStr = strings.TrimSpace(peerConnStr) + + peerInfo, err := wireproxy.ParseConnectionString(peerConnStr) + if err != nil { + log.Fatalf("Invalid connection string: %v", err) + } + session.PeerInfo = peerInfo + } + + fmt.Println("āœ“ Peer info received!") + fmt.Println("šŸ”— Establishing WireGuard tunnel...") + + // Build WireGuard config + wgConfig, err := session.BuildWireGuardConfig() + if err != nil { + log.Fatalf("Failed to build WireGuard config: %v", err) + } + + // Start WireGuard + logLevel := device.LogLevelVerbose + if silent { + logLevel = device.LogLevelSilent + } + + tun, err := wireproxy.StartWireguard(wgConfig, logLevel) + if err != nil { + log.Fatalf("Failed to start WireGuard: %v", err) + } + + // Add tunnel routines based on mode + if mode == "host" { + // Host: expose local port to tunnel + tunnelConfig := &wireproxy.TCPServerTunnelConfig{ + ListenPort: exposePort, + Target: fmt.Sprintf("127.0.0.1:%d", exposePort), + } + go tunnelConfig.SpawnRoutine(tun) + + fmt.Printf("\nšŸš€ \033[1;32mTunnel active!\033[0m\n") + fmt.Printf(" Exposing localhost:%d to peer at 10.0.0.1:%d\n\n", exposePort, exposePort) + } else { + // Join: bind local port to tunnel + bindAddr := fmt.Sprintf("127.0.0.1:%d", localPort) + tcpAddr, _ := net.ResolveTCPAddr("tcp", bindAddr) + tunnelConfig := &wireproxy.TCPClientTunnelConfig{ + BindAddress: tcpAddr, + Target: fmt.Sprintf("10.0.0.1:%d", localPort), + } + go tunnelConfig.SpawnRoutine(tun) + + fmt.Printf("\nšŸš€ \033[1;32mTunnel active!\033[0m\n") + fmt.Printf(" Connect to localhost:%d to reach peer's service\n\n", localPort) + } + + // Keep running + tun.StartPingIPs() +} + func main() { s := make(chan os.Signal, 1) signal.Notify(s, syscall.SIGINT, syscall.SIGQUIT) @@ -181,6 +306,12 @@ func main() { printVerison := parser.Flag("v", "version", &argparse.Options{Help: "Print version"}) configTest := parser.Flag("n", "configtest", &argparse.Options{Help: "Configtest mode. Only check the configuration file for validity."}) + // Hole punch flags + holePunch := parser.Flag("", "holepunch", &argparse.Options{Help: "Enable NAT hole punching mode"}) + exposePort := parser.Int("", "expose", &argparse.Options{Help: "Host mode: local port to expose to peer"}) + peerCode := parser.String("", "code", &argparse.Options{Help: "Join mode: peer's wormhole code"}) + localPort := parser.Int("", "local", &argparse.Options{Help: "Join mode: local port to bind for accessing remote service"}) + err := parser.Parse(args) if err != nil { fmt.Print(parser.Usage(err)) @@ -192,13 +323,20 @@ func main() { return } + // Handle hole punch mode + if *holePunch { + runHolePunch(*exposePort, *peerCode, *localPort, *silent) + <-ctx.Done() + return + } + if *config == "" { - if path, config_exist := configFilePath(); config_exist { - *config = path - } else { - fmt.Println("configuration path is required") - return - } + if path, config_exist := configFilePath(); config_exist { + *config = path + } else { + fmt.Println("configuration path is required") + return + } } if !*daemon { diff --git a/go.mod b/go.mod index b022b0a..0719390 100644 --- a/go.mod +++ b/go.mod @@ -8,18 +8,28 @@ require ( github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/akamensky/argparse v1.4.0 github.com/go-ini/ini v1.67.0 + github.com/google/uuid v1.6.0 github.com/landlock-lsm/go-landlock v0.0.0-20240216195629-efb66220540a + github.com/miekg/dns v1.1.58 + github.com/pion/stun/v2 v2.0.0 github.com/things-go/go-socks5 v0.0.5 golang.org/x/net v0.33.0 + golang.org/x/sys v0.28.0 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 suah.dev/protect v1.2.3 ) require ( github.com/google/btree v1.1.2 // indirect + github.com/pion/dtls/v2 v2.2.7 // indirect + github.com/pion/logging v0.2.2 // indirect + github.com/pion/transport/v2 v2.2.1 // indirect + github.com/pion/transport/v3 v3.0.1 // indirect golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/mod v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.17.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect kernel.org/pub/linux/libs/security/libcap/psx v1.2.69 // indirect diff --git a/go.sum b/go.sum index f51522a..ea71685 100644 --- a/go.sum +++ b/go.sum @@ -2,33 +2,109 @@ github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZ github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/akamensky/argparse v1.4.0 h1:YGzvsTqCvbEZhL8zZu2AiA5nq805NZh75JNj4ajn1xc= github.com/akamensky/argparse v1.4.0/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/landlock-lsm/go-landlock v0.0.0-20240216195629-efb66220540a h1:dz+a1MiMQksVhejeZwqJuzPawYQBwug74J8PPtkLl9U= github.com/landlock-lsm/go-landlock v0.0.0-20240216195629-efb66220540a/go.mod h1:1NY/VPO8xm3hXw3f+M65z+PJDLUaZA5cu7OfanxoUzY= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/things-go/go-socks5 v0.0.5 h1:qvKaGcBkfDrUL33SchHN93srAmYGzb4CxSM2DPYufe8= github.com/things-go/go-socks5 v0.0.5/go.mod h1:mtzInf8v5xmsBpHZVbIw2YQYhc4K0jRwzfsH64Uh0IQ= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ= diff --git a/holepunch.go b/holepunch.go new file mode 100644 index 0000000..0516430 --- /dev/null +++ b/holepunch.go @@ -0,0 +1,253 @@ +package wireproxy + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/netip" + "strings" + "time" + + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// DefaultRendezvousServer is the public rendezvous server for connection exchange +const DefaultRendezvousServer = "http://localhost:8080" + +// HolePunchConfig holds the configuration for hole punching mode +type HolePunchConfig struct { + // Mode: "host" exposes a service, "join" connects to one + Mode string + + // LocalPort for WireGuard to bind to (0 = random) + LocalPort uint16 + + // ExposePort: port to expose to peer (host mode) + ExposePort int + + // BindPort: local port to bind for accessing remote service (join mode) + BindPort int + + // Code: wormhole code for joining (join mode) + Code string + + // RendezvousServer: URL of rendezvous server + RendezvousServer string + + // STUNServers: list of STUN servers to use + STUNServers []string +} + +// ConnectionInfo represents the info exchanged between peers +type ConnectionInfo struct { + PublicKey string `json:"pubkey"` + Endpoint string `json:"endpoint"` + TunnelIP string `json:"tunnel_ip"` +} + +// HolePunchSession manages an active hole punch session +type HolePunchSession struct { + Config *HolePunchConfig + PrivateKey wgtypes.Key + PublicKey wgtypes.Key + NATInfo *NATInfo + PeerInfo *ConnectionInfo + Code string +} + +// wordList for generating human-readable codes +var wordList = []string{ + "apple", "banana", "cherry", "dragon", "eagle", "falcon", "grape", "honey", + "island", "jungle", "kiwi", "lemon", "mango", "nectar", "orange", "peach", + "quince", "raven", "sunset", "tiger", "umbrella", "violet", "walrus", "xenon", + "yellow", "zebra", "anchor", "breeze", "castle", "dolphin", "ember", "forest", +} + +// GenerateCode creates a human-readable wormhole code +func GenerateCode() string { + var b [3]byte + rand.Read(b[:]) + + num := int(b[0]) % 100 + word1 := wordList[int(b[1])%len(wordList)] + word2 := wordList[int(b[2])%len(wordList)] + + return fmt.Sprintf("%d-%s-%s", num, word1, word2) +} + +// NewHolePunchSession creates a new hole punch session +func NewHolePunchSession(config *HolePunchConfig) (*HolePunchSession, error) { + // Generate WireGuard keypair + privateKey, err := wgtypes.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate private key: %w", err) + } + + session := &HolePunchSession{ + Config: config, + PrivateKey: privateKey, + PublicKey: privateKey.PublicKey(), + } + + // Discover NAT + session.NATInfo, err = DiscoverNAT(config.LocalPort, config.STUNServers) + if err != nil { + return nil, fmt.Errorf("NAT discovery failed: %w", err) + } + + // Generate or use provided code + if config.Mode == "host" { + session.Code = GenerateCode() + } else { + session.Code = config.Code + } + + return session, nil +} + +// GetConnectionString returns the connection string to share with peer +func (s *HolePunchSession) GetConnectionString() string { + pubKeyB64 := base64.StdEncoding.EncodeToString(s.PublicKey[:]) + return fmt.Sprintf("hp://%s@%s", pubKeyB64, s.NATInfo.String()) +} + +// ParseConnectionString parses a connection string into ConnectionInfo +func ParseConnectionString(connStr string) (*ConnectionInfo, error) { + // Format: hp://BASE64_PUBKEY@IP:PORT + if !strings.HasPrefix(connStr, "hp://") { + return nil, fmt.Errorf("invalid connection string format") + } + + connStr = strings.TrimPrefix(connStr, "hp://") + parts := strings.Split(connStr, "@") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid connection string format") + } + + return &ConnectionInfo{ + PublicKey: parts[0], + Endpoint: parts[1], + }, nil +} + +// rendezvousPayload is the JSON payload for rendezvous API +type rendezvousPayload struct { + Code string `json:"code"` + PubKey string `json:"pubkey"` + Endpoint string `json:"endpoint"` + TunnelIP string `json:"tunnel_ip"` +} + +// ExchangeViaRendezvous exchanges connection info with peer via rendezvous server +func (s *HolePunchSession) ExchangeViaRendezvous() error { + server := s.Config.RendezvousServer + if server == "" { + server = DefaultRendezvousServer + } + + // Determine tunnel IP based on mode + tunnelIP := "10.0.0.1" + if s.Config.Mode == "join" { + tunnelIP = "10.0.0.2" + } + + // Prepare our info + payload := rendezvousPayload{ + Code: s.Code, + PubKey: base64.StdEncoding.EncodeToString(s.PublicKey[:]), + Endpoint: s.NATInfo.String(), + TunnelIP: tunnelIP, + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + // POST to rendezvous server + client := &http.Client{Timeout: 60 * time.Second} + + for attempt := 0; attempt < 60; attempt++ { + resp, err := client.Post(server+"/session", "application/json", bytes.NewReader(body)) + if err != nil { + time.Sleep(time.Second) + continue + } + + if resp.StatusCode == http.StatusAccepted { + // Peer hasn't connected yet, wait and retry + resp.Body.Close() + time.Sleep(time.Second) + continue + } + + if resp.StatusCode == http.StatusOK { + // Peer info received + var peerPayload rendezvousPayload + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if err := json.Unmarshal(respBody, &peerPayload); err != nil { + return fmt.Errorf("failed to parse peer info: %w", err) + } + + s.PeerInfo = &ConnectionInfo{ + PublicKey: peerPayload.PubKey, + Endpoint: peerPayload.Endpoint, + TunnelIP: peerPayload.TunnelIP, + } + return nil + } + + resp.Body.Close() + return fmt.Errorf("rendezvous server returned status %d", resp.StatusCode) + } + + return fmt.Errorf("timeout waiting for peer") +} + +// BuildWireGuardConfig generates a WireGuard configuration for the session +func (s *HolePunchSession) BuildWireGuardConfig() (*DeviceConfig, error) { + if s.PeerInfo == nil { + return nil, fmt.Errorf("peer info not available, call ExchangeViaRendezvous first") + } + + // Decode peer's public key + peerPubKeyBytes, err := base64.StdEncoding.DecodeString(s.PeerInfo.PublicKey) + if err != nil { + return nil, fmt.Errorf("invalid peer public key: %w", err) + } + + // Determine our tunnel IP + tunnelIP := "10.0.0.1" + peerTunnelIP := "10.0.0.2" + if s.Config.Mode == "join" { + tunnelIP = "10.0.0.2" + peerTunnelIP = "10.0.0.1" + } + + tunnelAddr, _ := netip.ParseAddr(tunnelIP) + peerPrefix, _ := netip.ParsePrefix(peerTunnelIP + "/32") + + listenPort := int(s.NATInfo.LocalPort) + + return &DeviceConfig{ + SecretKey: fmt.Sprintf("%x", s.PrivateKey[:]), + Endpoint: []netip.Addr{tunnelAddr}, + ListenPort: &listenPort, + MTU: 1420, + Peers: []PeerConfig{ + { + PublicKey: fmt.Sprintf("%x", peerPubKeyBytes), + Endpoint: &s.PeerInfo.Endpoint, + AllowedIPs: []netip.Prefix{peerPrefix}, + KeepAlive: 10, + PreSharedKey: "0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + }, nil +} diff --git a/stun.go b/stun.go new file mode 100644 index 0000000..ab2d5d7 --- /dev/null +++ b/stun.go @@ -0,0 +1,159 @@ +package wireproxy + +import ( + "fmt" + "net" + "net/netip" + "time" + + "github.com/pion/stun/v2" +) + +// NATInfo holds the discovered NAT mapping information +type NATInfo struct { + PublicIP netip.Addr + PublicPort uint16 + LocalPort uint16 +} + +// DefaultSTUNServers is the list of STUN servers to try +var DefaultSTUNServers = []string{ + "stun.l.google.com:19302", + "stun.cloudflare.com:3478", + "stun.stunprotocol.org:3478", +} + +// DiscoverNAT performs STUN discovery to find our public IP and port +func DiscoverNAT(localPort uint16, stunServers []string) (*NATInfo, error) { + if len(stunServers) == 0 { + stunServers = DefaultSTUNServers + } + + // Bind to the specified local port + localAddr := &net.UDPAddr{ + IP: net.IPv4zero, + Port: int(localPort), + } + + conn, err := net.ListenUDP("udp4", localAddr) + if err != nil { + return nil, fmt.Errorf("failed to bind to port %d: %w", localPort, err) + } + defer conn.Close() + + // Get the actual bound port (in case localPort was 0) + boundAddr := conn.LocalAddr().(*net.UDPAddr) + actualLocalPort := uint16(boundAddr.Port) + + var lastErr error + for _, server := range stunServers { + info, err := querySTUNServer(conn, server, actualLocalPort) + if err != nil { + lastErr = err + continue + } + return info, nil + } + + if lastErr != nil { + return nil, fmt.Errorf("all STUN servers failed, last error: %w", lastErr) + } + return nil, fmt.Errorf("no STUN servers configured") +} + +func querySTUNServer(conn *net.UDPConn, server string, localPort uint16) (*NATInfo, error) { + serverAddr, err := net.ResolveUDPAddr("udp4", server) + if err != nil { + return nil, fmt.Errorf("failed to resolve STUN server %s: %w", server, err) + } + + // Build STUN Binding Request + message := stun.MustBuild(stun.TransactionID, stun.BindingRequest) + + // Set read deadline + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + + // Send request + _, err = conn.WriteToUDP(message.Raw, serverAddr) + if err != nil { + return nil, fmt.Errorf("failed to send STUN request: %w", err) + } + + // Read response + buf := make([]byte, 1024) + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + return nil, fmt.Errorf("failed to read STUN response: %w", err) + } + + // Parse response + response := new(stun.Message) + response.Raw = buf[:n] + if err := response.Decode(); err != nil { + return nil, fmt.Errorf("failed to decode STUN response: %w", err) + } + + // Extract XOR-MAPPED-ADDRESS + var xorAddr stun.XORMappedAddress + if err := xorAddr.GetFrom(response); err != nil { + // Try regular MAPPED-ADDRESS as fallback + var mappedAddr stun.MappedAddress + if err := mappedAddr.GetFrom(response); err != nil { + return nil, fmt.Errorf("failed to get mapped address from STUN response: %w", err) + } + addr, ok := netip.AddrFromSlice(mappedAddr.IP) + if !ok { + return nil, fmt.Errorf("invalid IP address in STUN response") + } + return &NATInfo{ + PublicIP: addr, + PublicPort: uint16(mappedAddr.Port), + LocalPort: localPort, + }, nil + } + + addr, ok := netip.AddrFromSlice(xorAddr.IP) + if !ok { + return nil, fmt.Errorf("invalid IP address in STUN response") + } + + return &NATInfo{ + PublicIP: addr, + PublicPort: uint16(xorAddr.Port), + LocalPort: localPort, + }, nil +} + +// MaintainNATMapping sends periodic STUN requests to keep the NAT mapping alive +// Returns a stop function to cancel the maintenance goroutine +func MaintainNATMapping(localPort uint16, interval time.Duration) (stop func()) { + if interval == 0 { + interval = 20 * time.Second + } + + stopCh := make(chan struct{}) + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Use first server for keepalive + _, _ = DiscoverNAT(localPort, DefaultSTUNServers[:1]) + case <-stopCh: + return + } + } + }() + + return func() { + close(stopCh) + } +} + +// String returns a human-readable representation of NATInfo +func (n *NATInfo) String() string { + return fmt.Sprintf("%s:%d", n.PublicIP, n.PublicPort) +}