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/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). diff --git a/Dockerfile.wireworm b/Dockerfile.wireworm new file mode 100644 index 0000000..ca21db7 --- /dev/null +++ b/Dockerfile.wireworm @@ -0,0 +1,57 @@ +# Multi-stage build for a clean, efficient image +FROM docker.io/golang:1.21-bookworm AS build-wireproxy +WORKDIR /usr/src/wireproxy +COPY . . +RUN make + +# 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 \ + git \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/stuntman +RUN git clone https://github.com/jselbie/stunserver.git . && make + +# Final image +FROM docker.io/golang:1.21-bookworm + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + wireguard-tools \ + bash \ + curl \ + make \ + iproute2 \ + procps \ + libssl3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy binaries from build stages +# 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 + +# Metadata +LABEL org.opencontainers.image.title="WireWorm" +LABEL org.opencontainers.image.description="NAT Hole Punching PoC with userspace WireGuard and built-in STUN client" + +# Entrypoint setup +ENTRYPOINT ["/bin/bash", "./wireworm_interactive.sh"] 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/WIREWORM.md b/WIREWORM.md new file mode 100644 index 0000000..badc094 --- /dev/null +++ b/WIREWORM.md @@ -0,0 +1,61 @@ +# 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]`. + +## Features + +- **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 (Interactive) + +The easiest way to use WireWorm is via the interactive script: + +```bash +cd wireproxy +bash wireworm_interactive.sh +``` + +### Usage (Docker) + +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 +``` + +**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. +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/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/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/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) +} 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/test_utils/wireworm_chat.go b/test_utils/wireworm_chat.go new file mode 100644 index 0000000..6f3434b --- /dev/null +++ b/test_utils/wireworm_chat.go @@ -0,0 +1,120 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "log" + "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() { + 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(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) + } + conn, err = ln.Accept() + if err != nil { + log.Fatal(err) + } + fmt.Println(ColorGreen + "Peer connected! Start typing (type '/ping' for RTT, Ctrl+C to quit)." + ColorNC) + } else { + 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(ColorGreen + "Connected to peer! Start typing (type '/ping' for RTT, Ctrl+C to quit)." + ColorNC) + } + + defer conn.Close() + + // Receiver loop + go func() { + 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() + trimmed := strings.TrimSpace(text) + + if trimmed == "" { + fmt.Print("You: ") + continue + } + + 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: ") + } +} diff --git a/test_utils/wireworm_sender.go b/test_utils/wireworm_sender.go new file mode 100644 index 0000000..eb03278 --- /dev/null +++ b/test_utils/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) +} diff --git a/wireworm.sh b/wireworm.sh new file mode 100644 index 0000000..441ac5c --- /dev/null +++ b/wireworm.sh @@ -0,0 +1,89 @@ +#!/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 + +# 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" + + # 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..." + 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" + 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_interactive.sh b/wireworm_interactive.sh new file mode 100755 index 0000000..1d03656 --- /dev/null +++ b/wireworm_interactive.sh @@ -0,0 +1,366 @@ +#!/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 ! 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 +fi + +# 2. Key Generation +PRIV=$(wg genkey) +PUB=$(echo "$PRIV" | wg pubkey) + +# --- NAT Discovery --- +# 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 -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="" + +for s in "${STUN_SERVERS[@]}"; do + server=$(echo $s | cut -d':' -f1) + port=$(echo $s | cut -d':' -f2) + 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) + + # 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 + # 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 + + 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}" + +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}" + PUB_IP=$(curl -s https://api.ipify.org || echo "unknown") + PUB_PORT=$LOCAL_WG_PORT +fi + +# --- 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 +while true; do + 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-4]$ ]]; then break; fi + echo -e "${RED}Invalid selection.${NC}" +done + +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="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}" +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 true; do + 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 + 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}" +while true; do + 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 +FILE_TO_SEND="" +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) + 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" == "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 + 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}" +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 +./wireproxy -c wireworm.conf -i 127.0.0.1:8081 > wireproxy.log 2>&1 & +WIREPROXY_PID=$! + +# Handle shutdown +# (Cleanup is now handled by the 'cleanup' function above) + +# Handshake Monitor Loop +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) + HANDSHAKE=${HANDSHAKE:-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) + HANDSHAKE=${HANDSHAKE:-0} + if [[ ! "$HANDSHAKE" =~ ^[0-9]+$ ]]; then HANDSHAKE=0; fi + + if [ "$HANDSHAKE" -gt 0 ]; then + 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 + go run test_utils/wireworm_chat.go client 127.0.0.1:9003 + fi + break + fi + echo -ne "${YELLOW}Listening for peer... (No handshake yet) \r${NC}" + sleep 2 + done + + # 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}" + 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 + cleanup + exit 0 +fi