Skip to content

Commit 981194a

Browse files
chore: sticky session
1 parent 1b76ff5 commit 981194a

3 files changed

Lines changed: 230 additions & 2 deletions

File tree

cmd/sandbox/vpn.go

Lines changed: 200 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package sandbox
33
import (
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+
}

internal/api/devices.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,21 @@ func (c *SandboxClient) DeleteDeviceSession(ctx context.Context, deviceID, sessi
173173
}
174174
return nil
175175
}
176+
177+
// RenewDeviceSession bumps the session's expires_at forward by one
178+
// server-side TTL. The CLI's renewal goroutine calls this every ~TTL/2
179+
// while a tunnel is live. A 404 from this endpoint means the session
180+
// has expired or been deleted server-side — the caller's local WG iface
181+
// is now orphan and must be torn down. Use IsNotFound to discriminate.
182+
func (c *SandboxClient) RenewDeviceSession(ctx context.Context, deviceID, sessionID string) error {
183+
resp, err := c.Client.R().SetContext(ctx).
184+
SetPathParam("id", deviceID).SetPathParam("sid", sessionID).
185+
Put("/v1/devices/{id}/sessions/{sid}")
186+
if err != nil {
187+
return err
188+
}
189+
if resp.IsError() {
190+
return ParseAPIError(resp.StatusCode(), resp.Body())
191+
}
192+
return nil
193+
}

internal/api/types.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"encoding/json"
5+
"errors"
56
"fmt"
67
"net/http"
78
"sort"
@@ -18,6 +19,17 @@ func (e *APIError) Error() string {
1819
return e.Message
1920
}
2021

22+
// IsNotFound reports whether err is an APIError wrapping a 404.
23+
// Used by the VPN renewal loop to discriminate "server lost my session"
24+
// (tear-down signal) from a transient transport failure (retry).
25+
func IsNotFound(err error) bool {
26+
var ae *APIError
27+
if errors.As(err, &ae) {
28+
return ae.StatusCode == http.StatusNotFound
29+
}
30+
return false
31+
}
32+
2133
// Hint returns a contextual suggestion based on the HTTP status code.
2234
func (e *APIError) Hint() string {
2335
switch e.StatusCode {

0 commit comments

Comments
 (0)