@@ -3,8 +3,10 @@ package sandbox
33import (
44 "bytes"
55 "context"
6+ "encoding/json"
67 "fmt"
78 "io"
9+ "net"
810 "os"
911 "os/exec"
1012 "os/signal"
@@ -88,11 +90,41 @@ func runVPNUp(c *cli.Context) error {
8890 _ = tmp .Close () //nolint:errcheck // close-after-write — flush already done
8991 defer func () { _ = os .Remove (confPath ) }() //nolint:errcheck // best-effort cleanup of temp file
9092
93+ debug := c .Bool ("debug" )
94+
95+ // CGNAT / subnet conflict check. Our wg-quick config installs routes
96+ // for the device's CGNAT pool (100.64.0.0/10) plus the VM subnets the
97+ // device is authorised for. If any of those overlap with a route
98+ // already in the local routing table — e.g. the user is on Tailscale
99+ // (also 100.64.0.0/10), a corporate VPN with a 10.0.0.0/8 subnet, or
100+ // a home LAN happening to use 10.0.0.0/22 — installing OUR routes
101+ // would silently steal that traffic. Stop loudly instead.
102+ if conflict := detectRouteConflict (c .Context , conf ); conflict != "" {
103+ _ = closeSessionBestEffort (client , st .DeviceID , sess .SessionID ) //nolint:errcheck
104+ return fmt .Errorf (`route conflict detected: %s
105+
106+ another VPN or local network is already using an IP range that
107+ overlaps with your createos tunnel. Bringing up the tunnel would
108+ steal that traffic. Disconnect the other VPN (e.g. tailscale down)
109+ or remove the conflicting route, then re-run 'createos sb vpn up'` , conflict )
110+ }
111+
112+ // Defensive startup recovery: a prior CLI run that was killed (OOM,
113+ // kernel panic, force-quit) leaves the cosvpn iface in the kernel
114+ // without a matching server-side session. wg-quick up would then
115+ // fail with "RTNETLINK answers: File exists". Wipe any stale iface
116+ // before proceeding so the user doesn't have to manually intervene.
117+ if out , _ := exec .CommandContext (c .Context , "ip" , "link" , "show" , "cosvpn" ).Output (); len (out ) > 0 {
118+ cleanup := sudoCommand (c .Context , "wg-quick" , "down" , confPath )
119+ var cleanupBuf bytes.Buffer
120+ cleanup .Stdout , cleanup .Stderr = pickWGOutputs (debug , & cleanupBuf )
121+ _ = cleanup .Run () //nolint:errcheck // best-effort; if cosvpn was never up, this is a no-op
122+ }
123+
91124 // Bring the tunnel up. wg-quick echoes every shell command it runs
92125 // ("[#] wg setconf ...", "[#] ip route add ...") which is pure noise
93126 // for the happy path. Suppress unless --debug is set; on failure we
94127 // still want the captured output so the user can diagnose.
95- debug := c .Bool ("debug" )
96128 upCmd := sudoCommand (c .Context , "wg-quick" , "up" , confPath )
97129 var upBuf bytes.Buffer
98130 upCmd .Stdout , upCmd .Stderr = pickWGOutputs (debug , & upBuf )
@@ -110,9 +142,60 @@ func runVPNUp(c *cli.Context) error {
110142 pterm .Println (pterm .Gray (fmt .Sprintf (" iface: %s" , ifaceName )))
111143 pterm .Println (pterm .Gray ("Press Ctrl-C to disconnect." ))
112144
113- // Block until Ctrl-C / SIGTERM. Best-effort cleanup on the way out.
145+ // Block until Ctrl-C / SIGTERM (user disconnect) or until the
146+ // renewal goroutine signals that the server-side session is gone.
147+ // Best-effort cleanup on the way out either way.
114148 sig := make (chan os.Signal , 1 )
115149 signal .Notify (sig , os .Interrupt , syscall .SIGTERM )
150+
151+ // Renewal goroutine: PUT /sessions/:id every ~TTL/2 so an active
152+ // tunnel keeps its server-side session alive. If the server returns
153+ // 404 (sweeper got it, admin revoked, etc), or if two consecutive
154+ // renews fail with network errors, we self-signal a teardown — the
155+ // local WG iface without a matching server session is silently
156+ // broken and we'd rather the user know than have it look-alive-but-
157+ // fail-quietly.
158+ const renewInterval = 30 * time .Second
159+ renewCtx , cancelRenew := context .WithCancel (c .Context )
160+ defer cancelRenew ()
161+ go func () {
162+ t := time .NewTicker (renewInterval )
163+ defer t .Stop ()
164+ misses := 0
165+ for {
166+ select {
167+ case <- renewCtx .Done ():
168+ return
169+ case <- t .C :
170+ ctx2 , cancel := context .WithTimeout (renewCtx , 10 * time .Second )
171+ err := client .RenewDeviceSession (ctx2 , st .DeviceID , sess .SessionID )
172+ cancel ()
173+ if err == nil {
174+ misses = 0
175+ continue
176+ }
177+ if api .IsNotFound (err ) {
178+ pterm .Warning .Printfln ("session lost server-side — tearing down tunnel" )
179+ select {
180+ case sig <- syscall .SIGTERM :
181+ default :
182+ }
183+ return
184+ }
185+ misses ++
186+ pterm .Warning .Printfln ("renew failed (%d/2): %v" , misses , err )
187+ if misses >= 2 {
188+ pterm .Error .Println ("renewal repeatedly failed — assuming server lost session" )
189+ select {
190+ case sig <- syscall .SIGTERM :
191+ default :
192+ }
193+ return
194+ }
195+ }
196+ }
197+ }()
198+
116199 <- sig
117200
118201 pterm .Println ()
@@ -179,3 +262,118 @@ func closeSessionBestEffort(client *api.SandboxClient, deviceID, sessionID strin
179262 defer cancel ()
180263 return client .DeleteDeviceSession (ctx , deviceID , sessionID )
181264}
265+
266+ // detectRouteConflict checks whether any of the AllowedIPs in our
267+ // pending wg-quick config overlaps with a route already in the local
268+ // routing table that points at a DIFFERENT interface. Returns a
269+ // human-readable description of the first conflict found, or "" if none.
270+ //
271+ // Skips loopback + own-iface routes. "ip" missing or output unparseable
272+ // → returns "" (best-effort; we'd rather let wg-quick try and surface
273+ // its own error than block on a tooling absence).
274+ func detectRouteConflict (ctx context.Context , conf string ) string {
275+ allowed := parseAllowedIPs (conf )
276+ if len (allowed ) == 0 {
277+ return ""
278+ }
279+ existing := listLocalRoutes (ctx )
280+ if len (existing ) == 0 {
281+ return ""
282+ }
283+ for _ , ours := range allowed {
284+ for _ , theirs := range existing {
285+ if theirs .iface == "cosvpn" || theirs .iface == "lo" {
286+ continue // our own iface (stale from prior run) or loopback
287+ }
288+ if cidrsOverlap (ours , theirs .dst ) {
289+ return fmt .Sprintf ("%s (ours) overlaps %s on dev %s" ,
290+ ours .String (), theirs .dst .String (), theirs .iface )
291+ }
292+ }
293+ }
294+ return ""
295+ }
296+
297+ // parseAllowedIPs pulls every CIDR from the [Peer] AllowedIPs line(s)
298+ // of a wg-quick config. Robust to comma-separated and multi-line forms.
299+ func parseAllowedIPs (conf string ) []* net.IPNet {
300+ var out []* net.IPNet
301+ for _ , line := range strings .Split (conf , "\n " ) {
302+ line = strings .TrimSpace (line )
303+ if ! strings .HasPrefix (strings .ToLower (line ), "allowedips" ) {
304+ continue
305+ }
306+ eq := strings .IndexByte (line , '=' )
307+ if eq < 0 {
308+ continue
309+ }
310+ for _ , item := range strings .Split (line [eq + 1 :], "," ) {
311+ item = strings .TrimSpace (item )
312+ if item == "" {
313+ continue
314+ }
315+ if _ , cidr , err := net .ParseCIDR (item ); err == nil {
316+ out = append (out , cidr )
317+ }
318+ }
319+ }
320+ return out
321+ }
322+
323+ type localRoute struct {
324+ dst * net.IPNet
325+ iface string
326+ }
327+
328+ // listLocalRoutes shells out to `ip -j route show` and parses the JSON.
329+ // `ip` is part of iproute2 — available everywhere wg-quick runs (Linux
330+ // + Homebrew on macOS via `iproute2mac` — though macOS doesn't actually
331+ // have `ip` by default, so on macOS this returns nil and the check is
332+ // effectively a no-op there. wg-quick's own conflict detection takes
333+ // over.)
334+ func listLocalRoutes (ctx context.Context ) []localRoute {
335+ out , err := exec .CommandContext (ctx , "ip" , "-j" , "route" , "show" ).Output ()
336+ if err != nil {
337+ return nil
338+ }
339+ var raw []struct {
340+ Dst string `json:"dst"`
341+ Dev string `json:"dev"`
342+ }
343+ if err := json .Unmarshal (out , & raw ); err != nil {
344+ return nil
345+ }
346+ routes := make ([]localRoute , 0 , len (raw ))
347+ for _ , r := range raw {
348+ if r .Dst == "" || r .Dst == "default" {
349+ continue
350+ }
351+ // `ip -j` emits a bare IP for /32 routes; tack on the mask so
352+ // ParseCIDR is happy.
353+ dstStr := r .Dst
354+ if ! strings .Contains (dstStr , "/" ) {
355+ if ip := net .ParseIP (dstStr ); ip != nil {
356+ if ip .To4 () != nil {
357+ dstStr += "/32"
358+ } else {
359+ dstStr += "/128"
360+ }
361+ }
362+ }
363+ _ , cidr , err := net .ParseCIDR (dstStr )
364+ if err != nil {
365+ continue
366+ }
367+ routes = append (routes , localRoute {dst : cidr , iface : r .Dev })
368+ }
369+ return routes
370+ }
371+
372+ // cidrsOverlap reports whether two networks share any IP. Either one
373+ // being a superset of the other (or both equal) counts.
374+ func cidrsOverlap (a , b * net.IPNet ) bool {
375+ if a == nil || b == nil {
376+ return false
377+ }
378+ return a .Contains (b .IP ) || b .Contains (a .IP )
379+ }
0 commit comments