Skip to content

Commit 1ce475a

Browse files
committed
Adds edge connection
1 parent 20e19bd commit 1ce475a

File tree

5 files changed

+950
-5
lines changed

5 files changed

+950
-5
lines changed

README.md

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# vprox
22

3-
vprox is a high-performance network proxy acting as a split tunnel VPN. The server accepts peering requests from clients, which then establish WireGuard tunnels that direct all traffic on the client's network interface through the server, with IP masquerading.
3+
vprox is a high-performance network proxy acting as a split tunnel VPN. The server accepts peering requests from clients, which then establish WireGuard tunnels that direct all traffic on the client's network interface through the server, with IP masquerading. It also supports **edge connectors** that allow clients to reach private resources inside a remote VPC through the server.
44

5-
Both the client and server commands need root access. The server can have multiple public IP addresses attached, and on cloud providers, it automatically uses the instance metadata endpoint to discover its public IP addresses and start one proxy for each.
5+
Both the client, server, and edge commands need root access. The server can have multiple public IP addresses attached, and on cloud providers, it automatically uses the instance metadata endpoint to discover its public IP addresses and start one proxy for each.
66

77
This property allows the server to be high-availability. In the event of a restart or network partition, the tunnels remain open. If the server's IP address is attached to a new host, clients will automatically re-establish connections. This means that IP addresses can be moved to different hosts in event of an outage.
88

@@ -135,6 +135,76 @@ Note that Machine B must be able to send UDP packets to port 50227 on Machine A,
135135

136136
All outbound network traffic seen by `vprox0` will automatically be forwarded through the WireGuard tunnel. The VPN server masquerades the source IP address.
137137

138+
### Edge Connector
139+
140+
The `vprox edge` command deploys an edge connector inside a customer's VPC that makes an **outbound** connection to a vprox server and acts as an exit node into the local network. This allows `connect` clients to access private resources (databases, internal APIs, etc.) in the remote VPC through the server — without opening any inbound ports in the customer's network.
141+
142+
```
143+
WireGuard WireGuard
144+
┌──────────────┐ UDP tunnel ┌──────────────┐ UDP tunnel ┌──────────────────┐
145+
│ │◄────────────────────────► │ │◄────────────►│ │
146+
│ connect │ routes all traffic │ vprox │ advertises │ edge connector │
147+
│ client │ through server │ server │ 10.0.5.0/24 │ (customer VPC) │
148+
│ │ │ (public) │ │ │
149+
└──────────────┘ └──────────────┘ └────────┬─────────┘
150+
Sees 10.0.5.x Routes 10.0.5.0/24 │
151+
traffic go through toward edge peer ┌───────┴────────┐
152+
the tunnel │ Private │
153+
│ resources │
154+
│ 10.0.5.0/24 │
155+
└────────────────┘
156+
```
157+
158+
**How it works:**
159+
160+
1. The edge connector makes an outbound HTTPS request to the server (`POST /edge-connect`), advertising which CIDR routes it can reach.
161+
2. The server registers the edge as a WireGuard peer with `AllowedIPs` set to both the peer's tunnel address and the advertised routes.
162+
3. When a `connect` client sends a packet to a private IP (e.g. `10.0.5.100`), the server's WireGuard interface routes it to the edge peer.
163+
4. The edge connector receives the packet, masquerades the source IP, and forwards it into the local VPC network.
164+
5. Replies follow the same path in reverse.
165+
166+
**Edge setup (inside customer VPC):**
167+
168+
On the edge host, install system requirements:
169+
170+
```bash
171+
# On Amazon Linux
172+
sudo dnf install -y iptables wireguard-tools
173+
sudo sysctl -w net.ipv4.ip_forward=1
174+
sudo sysctl -w net.ipv4.conf.all.rp_filter=2
175+
```
176+
177+
Then start the edge connector, pointing it at the vprox server and advertising the local routes:
178+
179+
```bash
180+
# [Machine C: inside customer VPC, can reach 10.0.5.0/24]
181+
VPROX_PASSWORD=my-password vprox edge 1.2.3.4 \
182+
--routes 10.0.5.0/24 \
183+
--interface vprox-edge0
184+
```
185+
186+
**Full three-machine example:**
187+
188+
```bash
189+
# [Machine A: vprox server, public IP 1.2.3.4, private IP 172.31.64.125]
190+
VPROX_PASSWORD=my-password vprox server --ip 172.31.64.125 --wg-block 240.1.0.0/16
191+
192+
# [Machine C: edge connector in customer VPC, can reach 10.0.5.0/24]
193+
VPROX_PASSWORD=my-password vprox edge 1.2.3.4 --routes 10.0.5.0/24
194+
195+
# [Machine B: connect client]
196+
VPROX_PASSWORD=my-password vprox connect 1.2.3.4 --interface vprox0
197+
curl --interface vprox0 http://10.0.5.100:8080 # => reaches private resource
198+
```
199+
200+
**Notes:**
201+
202+
- The edge connector only requires **outbound** connectivity to the server (UDP port 50227 and TCP port 443). No inbound firewall rules are needed in the customer's VPC.
203+
- WireGuard's `PersistentKeepalive` (25s) keeps the NAT mapping alive for the outbound UDP tunnel.
204+
- Multiple edge connectors can connect to the same server as long as their advertised routes don't overlap.
205+
- The edge automatically reconnects if the tunnel goes unhealthy.
206+
- The `--routes` flag accepts multiple comma-separated CIDRs (e.g. `--routes 10.0.5.0/24,172.16.0.0/12`).
207+
138208
### Building
139209

140210
To build `vprox`, run the following command with Go 1.22+ installed:
@@ -164,6 +234,7 @@ On AWS in particular, the `--cloud aws` option allows you to automatically disco
164234
- Optimized for throughput with automatic MTU, MSS, GSO/GRO, and multi-queue configuration
165235
- Connection tracking bypass (NOTRACK) for reduced CPU overhead on WireGuard UDP flows
166236
- OIDC authentication for passwordless auth from Modal containers (`oidc-modal`)
237+
- Edge connectors for accessing private VPC resources through the server (outbound-only, no inbound ports required)
167238

168239
## Authors
169240

cmd/edge.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"errors"
7+
"fmt"
8+
"log"
9+
"net"
10+
"net/http"
11+
"net/netip"
12+
"os/signal"
13+
"strings"
14+
"syscall"
15+
"time"
16+
17+
"github.com/spf13/cobra"
18+
"golang.zx2c4.com/wireguard/wgctrl"
19+
20+
"github.com/modal-labs/vprox/lib"
21+
)
22+
23+
const edgeHealthCheckInterval = 2 * time.Second
24+
const edgeHealthCheckTimeout = 5 * time.Second
25+
const edgeReconnectInterval = 2 * time.Second
26+
27+
var EdgeCmd = &cobra.Command{
28+
Use: "edge [flags] <server-ip>",
29+
Short: "Connect to a VPN server as an edge node, advertising local routes",
30+
Args: cobra.ExactArgs(1),
31+
ArgAliases: []string{"server-ip"},
32+
RunE: runEdge,
33+
}
34+
35+
var edgeCmdArgs struct {
36+
ifname string
37+
routes string
38+
}
39+
40+
func init() {
41+
EdgeCmd.Flags().StringVar(&edgeCmdArgs.ifname, "interface",
42+
"vprox-edge0", "WireGuard interface name for the edge tunnel")
43+
EdgeCmd.Flags().StringVar(&edgeCmdArgs.routes, "routes",
44+
"", "Comma-separated CIDR prefixes to advertise (e.g. 10.0.5.0/24,172.16.0.0/12)")
45+
}
46+
47+
func runEdge(cmd *cobra.Command, args []string) error {
48+
serverIp, err := netip.ParseAddr(args[0])
49+
if err != nil || !serverIp.Is4() {
50+
return fmt.Errorf("invalid IPv4 address: %s", args[0])
51+
}
52+
53+
if edgeCmdArgs.routes == "" {
54+
return errors.New("missing required flag: --routes")
55+
}
56+
57+
// Parse and validate routes.
58+
var routes []netip.Prefix
59+
for _, r := range strings.Split(edgeCmdArgs.routes, ",") {
60+
r = strings.TrimSpace(r)
61+
if r == "" {
62+
continue
63+
}
64+
prefix, err := netip.ParsePrefix(r)
65+
if err != nil {
66+
return fmt.Errorf("invalid route CIDR %q: %v", r, err)
67+
}
68+
if !prefix.Addr().Is4() {
69+
return fmt.Errorf("only IPv4 routes are supported: %q", r)
70+
}
71+
routes = append(routes, prefix.Masked())
72+
}
73+
if len(routes) == 0 {
74+
return errors.New("at least one route must be specified with --routes")
75+
}
76+
77+
key, err := lib.GetClientKey(edgeCmdArgs.ifname)
78+
if err != nil {
79+
return fmt.Errorf("failed to load edge key: %v", err)
80+
}
81+
82+
token, err := lib.GetClientToken()
83+
if err != nil {
84+
return err
85+
}
86+
87+
wgClient, err := wgctrl.New()
88+
if err != nil {
89+
return fmt.Errorf("failed to initialize wgctrl: %v", err)
90+
}
91+
92+
edge := &lib.EdgeClient{
93+
Key: key,
94+
Ifname: edgeCmdArgs.ifname,
95+
ServerIp: serverIp,
96+
Token: token,
97+
Routes: routes,
98+
WgClient: wgClient,
99+
Http: &http.Client{
100+
Timeout: 5 * time.Second,
101+
Transport: &http.Transport{
102+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
103+
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
104+
return dialWithRetry(ctx, network, addr)
105+
},
106+
},
107+
},
108+
}
109+
110+
ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
111+
defer done()
112+
113+
if err := edge.CreateInterface(); err != nil {
114+
return err
115+
}
116+
defer edge.DeleteInterface()
117+
118+
if err := edge.Connect(); err != nil {
119+
return err
120+
}
121+
defer func() {
122+
log.Println("Sending /edge-disconnect request to server.")
123+
if err := edge.Disconnect(); err != nil {
124+
log.Printf("warning: failed to disconnect from server: %v", err)
125+
}
126+
}()
127+
128+
if err := edge.SetupForwarding(); err != nil {
129+
return fmt.Errorf("failed to set up forwarding: %v", err)
130+
}
131+
defer func() {
132+
if err := edge.CleanupForwarding(); err != nil {
133+
log.Printf("warning: failed to clean up forwarding rules: %v", err)
134+
}
135+
}()
136+
137+
log.Printf("Edge connected, advertising routes: %v", routes)
138+
if !edge.CheckConnection(edgeHealthCheckTimeout, ctx) {
139+
return fmt.Errorf("edge connection failed initial healthcheck after %v", edgeHealthCheckTimeout)
140+
}
141+
142+
for {
143+
select {
144+
case <-ctx.Done():
145+
log.Println("Context done. Returning from runEdge.")
146+
return nil
147+
case <-time.After(edgeHealthCheckInterval):
148+
}
149+
150+
if !edge.CheckConnection(edgeHealthCheckTimeout, ctx) {
151+
log.Println("Edge tunnel unhealthy. Attempting to reconnect...")
152+
unhealthy_loop:
153+
for {
154+
err = edge.Connect()
155+
if err == nil {
156+
log.Println("Edge reconnected.")
157+
break unhealthy_loop
158+
}
159+
if !lib.IsRecoverableError(err) {
160+
return fmt.Errorf("unrecoverable edge connection error: %w", err)
161+
}
162+
log.Printf("Failed to reconnect edge: %v", err)
163+
select {
164+
case <-ctx.Done():
165+
log.Println("Context done during reconnect. Breaking out.")
166+
break unhealthy_loop
167+
case <-time.After(edgeReconnectInterval):
168+
}
169+
}
170+
}
171+
}
172+
}

0 commit comments

Comments
 (0)