diff --git a/cmd/sandbox/devices.go b/cmd/sandbox/devices.go new file mode 100644 index 0000000..3f73d06 --- /dev/null +++ b/cmd/sandbox/devices.go @@ -0,0 +1,244 @@ +package sandbox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" +) + +// deviceState is what we persist locally after `devices register`. The +// private key is the secret half of the WG keypair — server only ever +// sees the pubkey. Keeping it local means even a control-plane breach +// can't read traffic that flowed through us. +type deviceState struct { + DeviceID string `json:"device_id"` + Name string `json:"name"` + ClientIP string `json:"client_ip"` + PrivateKey string `json:"private_key"` // base64 + Pubkey string `json:"pubkey"` // base64 + Hostname string `json:"hostname,omitempty"` + Server string `json:"server,omitempty"` +} + +func deviceStatePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "createos", "device.json"), nil +} + +func loadDeviceState() (*deviceState, error) { + p, err := deviceStatePath() + if err != nil { + return nil, err + } + b, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var st deviceState + if err := json.Unmarshal(b, &st); err != nil { + return nil, err + } + return &st, nil +} + +func saveDeviceState(st deviceState) error { + p, err := deviceStatePath() + if err != nil { + return err + } + if mkErr := os.MkdirAll(filepath.Dir(p), 0o700); mkErr != nil { + return mkErr + } + // gosec G117: this struct intentionally serializes the device's WG + // private key — stored at 0o600 in the user's config dir; the server + // never sees it. Encrypting at rest is out of scope for v1. + b, err := json.MarshalIndent(st, "", " ") //nolint:gosec + if err != nil { + return err + } + return os.WriteFile(p, b, 0o600) +} + +func clearDeviceState() error { + p, err := deviceStatePath() + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +// genWGKeypair shells out to `wg genkey | wg pubkey`. Requires +// wireguard-tools installed — the same prerequisite as `vpn up`. +func genWGKeypair(ctx context.Context) (priv, pub string, err error) { + if _, lookErr := exec.LookPath("wg"); lookErr != nil { + fmt.Fprintln(os.Stderr, wgInstallHint) + return "", "", errNoWG + } + out, err := exec.CommandContext(ctx, "wg", "genkey").Output() + if err != nil { + return "", "", fmt.Errorf("wg genkey: %w", err) + } + priv = strings.TrimSpace(string(out)) + + cmd := exec.CommandContext(ctx, "wg", "pubkey") + cmd.Stdin = strings.NewReader(priv + "\n") + out, err = cmd.Output() + if err != nil { + return "", "", fmt.Errorf("wg pubkey: %w", err) + } + pub = strings.TrimSpace(string(out)) + return priv, pub, nil +} + +// errNoWG is a sentinel returned when wireguard-tools isn't on PATH. +// The user-facing install hints are kept off the error string (revive's +// error-strings rule rejects multi-line/punctuated error text) and +// printed by callers via wgInstallHint(). +var errNoWG = errors.New("wireguard-tools not installed") + +// wgInstallHint is the multi-line install snippet for the platforms we +// care about. Printed alongside errNoWG so users see actionable advice +// without baking it into the error string. +const wgInstallHint = `wireguard-tools is required for VPN commands. + + macOS: brew install wireguard-tools + Ubuntu: sudo apt install -y wireguard-tools + Fedora: sudo dnf install -y wireguard-tools + +After installing, re-run this command.` + +// newDevicesCommand returns the `sb devices` group. Devices are this +// machine's identity to the network — register once, then `vpn up` +// brings up an encrypted tunnel into any of your attached networks. +func newDevicesCommand() *cli.Command { + return &cli.Command{ + Name: "devices", + Aliases: []string{"device"}, + Usage: "Register this machine to reach your private networks over VPN", + Subcommands: []*cli.Command{ + newDevicesRegisterCommand(), + newDevicesUnregisterCommand(), + }, + } +} + +func newDevicesRegisterCommand() *cli.Command { + return &cli.Command{ + Name: "register", + Usage: "Register this machine. One-time per laptop/desktop.", + ArgsUsage: "[]", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "name", Usage: "human-readable device name (default: machine hostname)"}, + }, + Action: runDeviceRegister, + } +} + +func runDeviceRegister(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + // Already registered? Surface that instead of silently double-registering. + existing, _ := loadDeviceState() //nolint:errcheck // missing/corrupt file = not registered, fall through + if existing != nil { + pterm.Warning.Printfln("This machine is already registered as %q (%s).", existing.Name, existing.ClientIP) + pterm.Println(pterm.Gray(" To re-register, first run: createos sb devices unregister")) + return nil + } + hostname, _ := os.Hostname() //nolint:errcheck // hostname is optional metadata + name := strings.TrimSpace(c.String("name")) + if name == "" { + name = strings.TrimSpace(c.Args().First()) + } + if name == "" { + name = hostname + } + if name == "" { + return fmt.Errorf("please give this device a name:\n\n createos sb devices register ") + } + + priv, pub, err := genWGKeypair(c.Context) + if err != nil { + return err + } + + view, err := client.CreateDevice(c.Context, api.DeviceCreateReq{ + Name: name, + Pubkey: pub, + Hostname: hostname, + OS: runtime.GOOS, + }) + if err != nil { + return err + } + st := deviceState{ + DeviceID: view.ID, + Name: view.Name, + ClientIP: view.ClientIP, + PrivateKey: priv, + Pubkey: pub, + Hostname: hostname, + } + if err := saveDeviceState(st); err != nil { + return fmt.Errorf("could not save device state: %w", err) + } + pterm.Success.Printfln("Registered %q (%s)", view.Name, view.ClientIP) + pterm.Println(pterm.Gray(" Attach this device to a network in the UI, then:")) + pterm.Println(pterm.Gray(" createos sb vpn up")) + return nil +} + +func newDevicesUnregisterCommand() *cli.Command { + return &cli.Command{ + Name: "unregister", + Usage: "Remove this machine from your devices", + Action: runDeviceUnregister, + } +} + +func runDeviceUnregister(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + st, err := loadDeviceState() + if err != nil || st == nil { + pterm.Info.Println("This machine isn't registered.") + return nil + } + ctx, cancel := context.WithTimeout(c.Context, 10*time.Second) + defer cancel() + if err := client.DeleteDevice(ctx, st.DeviceID); err != nil { + pterm.Warning.Printfln("Server-side revoke failed: %v", err) + pterm.Println(pterm.Gray(" (clearing local state anyway; revoke from the UI to be safe)")) + } + if err := clearDeviceState(); err != nil { + return fmt.Errorf("clear local state: %w", err) + } + pterm.Success.Printfln("Unregistered %q.", st.Name) + return nil +} + +// stub used by `createos sb devices ls` if you ever want it — silenced +// for now since the design specifies only register/unregister. +var _ = output.Render diff --git a/cmd/sandbox/network.go b/cmd/sandbox/network.go index 75d4fce..bf7adc0 100644 --- a/cmd/sandbox/network.go +++ b/cmd/sandbox/network.go @@ -312,47 +312,51 @@ func pickNetworksForDelete(c *cli.Context, client *api.SandboxClient) ([]string, func newNetworkAttachCommand() *cli.Command { return &cli.Command{ Name: "attach", - Usage: "Add a sandbox to a network", - ArgsUsage: "[ ]", + Usage: "Add a sandbox or device to a network", + ArgsUsage: "[ ]", Action: runNetworkAttach, } } +// isDeviceRef reports whether ref looks like a device id (dev-…) — used +// so `network attach dev-… ` routes to the device-attach API +// instead of the sandbox one. Plain prefix sniff: device ids are minted +// with this prefix and nothing else legitimately starts with it. +func isDeviceRef(ref string) bool { + return strings.HasPrefix(ref, "dev-") || strings.HasPrefix(ref, "dev_") +} + func runNetworkAttach(c *cli.Context) error { client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) if !ok { return fmt.Errorf("you're not signed in — run 'createos login' to get started") } args := c.Args().Slice() - sandboxRef, netRef := "", "" + ref, netRef := "", "" if len(args) > 0 { - sandboxRef = args[0] + ref = args[0] } if len(args) > 1 { netRef = args[1] } tty := terminal.IsInteractive() - if sandboxRef == "" { + if ref == "" { if !tty { - return fmt.Errorf("usage: createos sandbox network attach ") + return fmt.Errorf("usage: createos sandbox network attach ") } - pickedID, label, err := pickByStatus(c, client, "Attach which sandbox?", "running") + picked, err := pickEndpoint(c, client, "Attach what?") if err != nil { return err } - if pickedID == "" { + if picked == "" { fmt.Println("Cancelled.") return nil } - sandboxRef = label - } - sandboxID, err := resolveSandboxRef(c.Context, client, sandboxRef) - if err != nil { - return err + ref = picked } if netRef == "" { if !tty { - return fmt.Errorf("usage: createos sandbox network attach ") + return fmt.Errorf("usage: createos sandbox network attach ") } picked, err := pickNetwork(c, client, "Attach to which network?") if err != nil { @@ -364,10 +368,22 @@ func runNetworkAttach(c *cli.Context) error { } netRef = picked } + if isDeviceRef(ref) { + if err := client.AttachDeviceToNetwork(c.Context, ref, netRef); err != nil { + return err + } + pterm.Success.Printfln("Attached device %s → network %s", ref, netRef) + pterm.Println(pterm.Gray(" The device can now reach VMs on this network once it brings up the tunnel.")) + return nil + } + sandboxID, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } if err := client.AttachNetwork(c.Context, sandboxID, netRef); err != nil { return err } - pterm.Success.Printfln("Attached %s → network %s", refLabel(sandboxRef, sandboxID), netRef) + pterm.Success.Printfln("Attached %s → network %s", refLabel(ref, sandboxID), netRef) pterm.Println(pterm.Gray(" Other sandboxes on this network can now reach this one by name.")) return nil } @@ -377,8 +393,8 @@ func runNetworkAttach(c *cli.Context) error { func newNetworkDetachCommand() *cli.Command { return &cli.Command{ Name: "detach", - Usage: "Remove a sandbox from a network", - ArgsUsage: "[ ]", + Usage: "Remove a sandbox or device from a network", + ArgsUsage: "[ ]", Flags: []cli.Flag{ &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "Skip the confirmation prompt"}, }, @@ -392,35 +408,31 @@ func runNetworkDetach(c *cli.Context) error { return fmt.Errorf("you're not signed in — run 'createos login' to get started") } args := c.Args().Slice() - sandboxRef, netRef := "", "" + ref, netRef := "", "" if len(args) > 0 { - sandboxRef = args[0] + ref = args[0] } if len(args) > 1 { netRef = args[1] } tty := terminal.IsInteractive() - if sandboxRef == "" { + if ref == "" { if !tty { - return fmt.Errorf("usage: createos sandbox network detach ") + return fmt.Errorf("usage: createos sandbox network detach ") } - pickedID, label, err := pickByStatus(c, client, "Detach from which sandbox?", "running") + picked, err := pickEndpoint(c, client, "Detach what?") if err != nil { return err } - if pickedID == "" { + if picked == "" { fmt.Println("Cancelled.") return nil } - sandboxRef = label - } - sandboxID, err := resolveSandboxRef(c.Context, client, sandboxRef) - if err != nil { - return err + ref = picked } if netRef == "" { if !tty { - return fmt.Errorf("usage: createos sandbox network detach ") + return fmt.Errorf("usage: createos sandbox network detach ") } picked, err := pickNetwork(c, client, "Detach from which network?") if err != nil { @@ -436,9 +448,18 @@ func runNetworkDetach(c *cli.Context) error { if !tty && !force { return fmt.Errorf("non-interactive: pass --yes to confirm detach") } + displayLabel := ref + if !isDeviceRef(ref) { + // Resolve sandbox short-form (name → id) for the confirmation + // prompt + final success line. Devices skip this — they only + // come through as dev-… ids. + if sandboxID, sErr := resolveSandboxRef(c.Context, client, ref); sErr == nil { + displayLabel = refLabel(ref, sandboxID) + } + } if tty && !force { ok, err := pterm.DefaultInteractiveConfirm. - WithDefaultText(fmt.Sprintf("Remove %s from network %s?", refLabel(sandboxRef, sandboxID), netRef)). + WithDefaultText(fmt.Sprintf("Remove %s from network %s?", displayLabel, netRef)). WithDefaultValue(false). Show() if err != nil { @@ -449,13 +470,72 @@ func runNetworkDetach(c *cli.Context) error { return nil } } + if isDeviceRef(ref) { + if err := client.DetachDeviceFromNetwork(c.Context, ref, netRef); err != nil { + return err + } + pterm.Success.Printfln("Detached device %s from network %s", ref, netRef) + return nil + } + sandboxID, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } if err := client.DetachNetwork(c.Context, sandboxID, netRef); err != nil { return err } - pterm.Success.Printfln("Detached %s from network %s", refLabel(sandboxRef, sandboxID), netRef) + pterm.Success.Printfln("Detached %s from network %s", refLabel(ref, sandboxID), netRef) return nil } +// pickEndpoint shows a single-select picker that lists BOTH the caller's +// running sandboxes and registered devices, returning whichever ref the +// user picks (sb-… or dev-…). Used by `network attach` / `network detach` +// to support attaching devices alongside sandboxes in interactive mode. +func pickEndpoint(c *cli.Context, client *api.SandboxClient, title string) (string, error) { + // Sandboxes (running only — same filter as the old picker). + sbs, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{Limit: 200, Status: "running"}) + if err != nil { + return "", err + } + // Devices — registered ones for this user. + devs, err := client.ListDevices(c.Context) + if err != nil { + // Non-fatal: still let the user pick a sandbox if device list fails. + devs = nil + } + if len(sbs) == 0 && len(devs) == 0 { + fmt.Println("You don't have any running sandboxes or registered devices.") + pterm.Println(pterm.Gray(" Create one with: createos sandbox create")) + pterm.Println(pterm.Gray(" Or register this machine: createos sandbox devices register")) + return "", nil + } + options := make([]string, 0, len(sbs)+len(devs)) + refByOpt := make(map[string]string, len(sbs)+len(devs)) + for _, r := range sbs { + label := r.ID + if r.Name != nil && *r.Name != "" { + label = *r.Name + } + opt := fmt.Sprintf("sandbox: %s (id: %s)", label, r.ID) + options = append(options, opt) + refByOpt[opt] = r.ID + } + for _, d := range devs { + opt := fmt.Sprintf("device: %s (%s, id: %s)", d.Name, d.ClientIP, d.ID) + options = append(options, opt) + refByOpt[opt] = d.ID + } + picked, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultText(title). + Show() + if err != nil { + return "", fmt.Errorf("could not read your selection: %w", err) + } + return refByOpt[picked], nil +} + // pickNetwork renders a single-select picker over the caller's networks // and returns the picked NAME (the server accepts it wherever an ID // works). Returns "" when the user cancels. diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index b4704fd..0011422 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -34,6 +34,8 @@ func NewSandboxCommand() *cli.Command { newTemplateCommand(), newShapesCommand(), newRootfsCommand(), + newDevicesCommand(), + newVPNCommand(), }, } } diff --git a/cmd/sandbox/vpn.go b/cmd/sandbox/vpn.go new file mode 100644 index 0000000..661f719 --- /dev/null +++ b/cmd/sandbox/vpn.go @@ -0,0 +1,379 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +func newVPNCommand() *cli.Command { + return &cli.Command{ + Name: "vpn", + Usage: "Open a WireGuard tunnel into your private networks", + Subcommands: []*cli.Command{ + newVPNUpCommand(), + }, + } +} + +func newVPNUpCommand() *cli.Command { + return &cli.Command{ + Name: "up", + Usage: "Connect this machine to your networks. Stays up until you press Ctrl-C.", + Action: runVPNUp, + } +} + +func runVPNUp(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + st, err := loadDeviceState() + if err != nil || st == nil { + return fmt.Errorf(`this machine isn't registered yet. + + Run once: + createos sb devices register + + Then come back and: + createos sb vpn up`) + } + if _, lookErr := exec.LookPath("wg-quick"); lookErr != nil { + fmt.Fprintln(os.Stderr, wgInstallHint) + return errNoWG + } + + // Open a session up front so we fail fast if the API or relay are + // down — better than running wg-quick and discovering it later. + sessCtx, cancelSess := context.WithTimeout(c.Context, 10*time.Second) + sess, err := client.CreateDeviceSession(sessCtx, st.DeviceID) + cancelSess() + if err != nil { + return fmt.Errorf("could not open VPN session: %w", err) + } + + // Splice the locally-held private key into the server-issued config. + // The server's wg-quick config has no PrivateKey line (server never + // holds it); inject ours right after the [Interface] header. + conf := injectPrivateKey(sess.ClientConfig, st.PrivateKey) + + // Write the config to a temp file. wg-quick derives the kernel iface + // name from the config's basename (sans .conf). Linux caps iface + // names at 15 chars, and the name must match ^[a-zA-Z0-9_=+.-]+$, + // so we use a short fixed name (single tunnel per machine). + confPath := filepath.Join(os.TempDir(), "cosvpn.conf") + tmp, err := os.Create(confPath) + if err != nil { + return fmt.Errorf("temp file: %w", err) + } + if _, wErr := tmp.WriteString(conf); wErr != nil { + _ = tmp.Close() //nolint:errcheck // cleanup; original error wins + _ = os.Remove(confPath) //nolint:errcheck // cleanup; original error wins + return fmt.Errorf("write config: %w", wErr) + } + _ = tmp.Close() //nolint:errcheck // close-after-write — flush already done + defer func() { _ = os.Remove(confPath) }() //nolint:errcheck // best-effort cleanup of temp file + + debug := c.Bool("debug") + + // CGNAT / subnet conflict check. Our wg-quick config installs routes + // for the device's CGNAT pool (100.64.0.0/10) plus the VM subnets the + // device is authorised for. If any of those overlap with a route + // already in the local routing table — e.g. the user is on Tailscale + // (also 100.64.0.0/10), a corporate VPN with a 10.0.0.0/8 subnet, or + // a home LAN happening to use 10.0.0.0/22 — installing OUR routes + // would silently steal that traffic. Stop loudly instead. + if conflict := detectRouteConflict(c.Context, conf); conflict != "" { + _ = closeSessionBestEffort(client, st.DeviceID, sess.SessionID) //nolint:errcheck + return fmt.Errorf(`route conflict detected: %s + + another VPN or local network is already using an IP range that + overlaps with your createos tunnel. Bringing up the tunnel would + steal that traffic. Disconnect the other VPN (e.g. tailscale down) + or remove the conflicting route, then re-run 'createos sb vpn up'`, conflict) + } + + // Defensive startup recovery: a prior CLI run that was killed (OOM, + // kernel panic, force-quit) leaves the cosvpn iface in the kernel + // without a matching server-side session. wg-quick up would then + // fail with "RTNETLINK answers: File exists". Wipe any stale iface + // before proceeding so the user doesn't have to manually intervene. + if out, _ := exec.CommandContext(c.Context, "ip", "link", "show", "cosvpn").Output(); len(out) > 0 { //nolint:errcheck // best-effort stale-iface probe; absent iface yields empty out + cleanup := sudoCommand(c.Context, "wg-quick", "down", confPath) + var cleanupBuf bytes.Buffer + cleanup.Stdout, cleanup.Stderr = pickWGOutputs(debug, &cleanupBuf) + _ = cleanup.Run() //nolint:errcheck // best-effort; if cosvpn was never up, this is a no-op + } + + // Bring the tunnel up. wg-quick echoes every shell command it runs + // ("[#] wg setconf ...", "[#] ip route add ...") which is pure noise + // for the happy path. Suppress unless --debug is set; on failure we + // still want the captured output so the user can diagnose. + upCmd := sudoCommand(c.Context, "wg-quick", "up", confPath) + var upBuf bytes.Buffer + upCmd.Stdout, upCmd.Stderr = pickWGOutputs(debug, &upBuf) + if runErr := upCmd.Run(); runErr != nil { + if !debug { + _, _ = io.Copy(os.Stderr, &upBuf) //nolint:errcheck // diagnostic dump; original error wins + } + _ = closeSessionBestEffort(client, st.DeviceID, sess.SessionID) //nolint:errcheck // best-effort cleanup + return fmt.Errorf("wg-quick up: %w", runErr) + } + + ifaceName := strings.TrimSuffix(filepath.Base(confPath), ".conf") + pterm.Success.Printfln("VPN connected as %s (%s).", st.Name, st.ClientIP) + pterm.Println(pterm.Gray(fmt.Sprintf(" device: %s", st.Name))) + pterm.Println(pterm.Gray(fmt.Sprintf(" iface: %s", ifaceName))) + pterm.Println(pterm.Gray("Press Ctrl-C to disconnect.")) + + // Block until Ctrl-C / SIGTERM (user disconnect) or until the + // renewal goroutine signals that the server-side session is gone. + // Best-effort cleanup on the way out either way. + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + // Renewal goroutine: PUT /sessions/:id every ~TTL/2 so an active + // tunnel keeps its server-side session alive. If the server returns + // 404 (sweeper got it, admin revoked, etc), or if two consecutive + // renews fail with network errors, we self-signal a teardown — the + // local WG iface without a matching server session is silently + // broken and we'd rather the user know than have it look-alive-but- + // fail-quietly. + const renewInterval = 30 * time.Second + renewCtx, cancelRenew := context.WithCancel(c.Context) + defer cancelRenew() + go func() { + t := time.NewTicker(renewInterval) + defer t.Stop() + misses := 0 + for { + select { + case <-renewCtx.Done(): + return + case <-t.C: + ctx2, cancel := context.WithTimeout(renewCtx, 10*time.Second) + err := client.RenewDeviceSession(ctx2, st.DeviceID, sess.SessionID) + cancel() + if err == nil { + misses = 0 + continue + } + if api.IsNotFound(err) { + pterm.Warning.Printfln("session lost server-side — tearing down tunnel") + select { + case sig <- syscall.SIGTERM: + default: + } + return + } + misses++ + pterm.Warning.Printfln("renew failed (%d/2): %v", misses, err) + if misses >= 2 { + pterm.Error.Println("renewal repeatedly failed — assuming server lost session") + select { + case sig <- syscall.SIGTERM: + default: + } + return + } + } + } + }() + + <-sig + + pterm.Println() + pterm.Info.Println("Disconnecting...") + downCtx, cancelDown := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelDown() + downCmd := sudoCommand(downCtx, "wg-quick", "down", confPath) + var downBuf bytes.Buffer + downCmd.Stdout, downCmd.Stderr = pickWGOutputs(debug, &downBuf) + if runErr := downCmd.Run(); runErr != nil && !debug { + _, _ = io.Copy(os.Stderr, &downBuf) //nolint:errcheck // diagnostic dump + } + if err := closeSessionBestEffort(client, st.DeviceID, sess.SessionID); err != nil { + pterm.Warning.Printfln("Server-side session close failed: %v", err) + } + pterm.Success.Println("Disconnected.") + return nil +} + +// injectPrivateKey writes the client's locally-held private key into +// the server-issued wg-quick config. The server NEVER ships PrivateKey, +// so we append it as the last line of the [Interface] section. +func injectPrivateKey(config, privkey string) string { + const marker = "[Interface]" + idx := strings.Index(config, marker) + if idx < 0 { + // Defensive — server should always include [Interface]. Prepend + // as a fallback so wg-quick at least parses. + return fmt.Sprintf("[Interface]\nPrivateKey = %s\n%s", privkey, config) + } + insertAt := idx + len(marker) + return config[:insertAt] + "\nPrivateKey = " + privkey + config[insertAt:] +} + +// pickWGOutputs returns the (stdout, stderr) wiring for a wg-quick child. +// debug=true tees both through to the user's terminal; debug=false captures +// into buf so we can replay the diagnostic only on failure. +func pickWGOutputs(debug bool, buf *bytes.Buffer) (io.Writer, io.Writer) { + if debug { + return os.Stdout, os.Stderr + } + return buf, buf +} + +// sudoCommand wraps wg-quick with sudo on non-Windows. wg-quick needs +// CAP_NET_ADMIN to manage the kernel iface; running as a normal user +// always fails. Wrapping in sudo here is friendlier than telling the +// user to run the whole `createos` command as root. +// +// gosec G204: name/args are hard-coded callsites from this package +// ("wg-quick up " / "wg-quick down ") where confPath +// is generated inside vpn.go from os.TempDir() + a constant basename — +// no user input flows into either field. +func sudoCommand(ctx context.Context, name string, args ...string) *exec.Cmd { + if _, err := exec.LookPath("sudo"); err == nil { + full := append([]string{name}, args...) + return exec.CommandContext(ctx, "sudo", full...) //nolint:gosec + } + return exec.CommandContext(ctx, name, args...) //nolint:gosec +} + +func closeSessionBestEffort(client *api.SandboxClient, deviceID, sessionID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return client.DeleteDeviceSession(ctx, deviceID, sessionID) +} + +// detectRouteConflict checks whether any of the AllowedIPs in our +// pending wg-quick config overlaps with a route already in the local +// routing table that points at a DIFFERENT interface. Returns a +// human-readable description of the first conflict found, or "" if none. +// +// Skips loopback + own-iface routes. "ip" missing or output unparseable +// → returns "" (best-effort; we'd rather let wg-quick try and surface +// its own error than block on a tooling absence). +func detectRouteConflict(ctx context.Context, conf string) string { + allowed := parseAllowedIPs(conf) + if len(allowed) == 0 { + return "" + } + existing := listLocalRoutes(ctx) + if len(existing) == 0 { + return "" + } + for _, ours := range allowed { + for _, theirs := range existing { + if theirs.iface == "cosvpn" || theirs.iface == "lo" { + continue // our own iface (stale from prior run) or loopback + } + if cidrsOverlap(ours, theirs.dst) { + return fmt.Sprintf("%s (ours) overlaps %s on dev %s", + ours.String(), theirs.dst.String(), theirs.iface) + } + } + } + return "" +} + +// parseAllowedIPs pulls every CIDR from the [Peer] AllowedIPs line(s) +// of a wg-quick config. Robust to comma-separated and multi-line forms. +func parseAllowedIPs(conf string) []*net.IPNet { + var out []*net.IPNet + for _, line := range strings.Split(conf, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToLower(line), "allowedips") { + continue + } + eq := strings.IndexByte(line, '=') + if eq < 0 { + continue + } + for _, item := range strings.Split(line[eq+1:], ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, cidr, err := net.ParseCIDR(item); err == nil { + out = append(out, cidr) + } + } + } + return out +} + +type localRoute struct { + dst *net.IPNet + iface string +} + +// listLocalRoutes shells out to `ip -j route show` and parses the JSON. +// `ip` is part of iproute2 — available everywhere wg-quick runs (Linux +// + Homebrew on macOS via `iproute2mac` — though macOS doesn't actually +// have `ip` by default, so on macOS this returns nil and the check is +// effectively a no-op there. wg-quick's own conflict detection takes +// over.) +func listLocalRoutes(ctx context.Context) []localRoute { + out, err := exec.CommandContext(ctx, "ip", "-j", "route", "show").Output() + if err != nil { + return nil + } + var raw []struct { + Dst string `json:"dst"` + Dev string `json:"dev"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return nil + } + routes := make([]localRoute, 0, len(raw)) + for _, r := range raw { + if r.Dst == "" || r.Dst == "default" { + continue + } + // `ip -j` emits a bare IP for /32 routes; tack on the mask so + // ParseCIDR is happy. + dstStr := r.Dst + if !strings.Contains(dstStr, "/") { + if ip := net.ParseIP(dstStr); ip != nil { + if ip.To4() != nil { + dstStr += "/32" + } else { + dstStr += "/128" + } + } + } + _, cidr, err := net.ParseCIDR(dstStr) + if err != nil { + continue + } + routes = append(routes, localRoute{dst: cidr, iface: r.Dev}) + } + return routes +} + +// cidrsOverlap reports whether two networks share any IP. Either one +// being a superset of the other (or both equal) counts. +func cidrsOverlap(a, b *net.IPNet) bool { + if a == nil || b == nil { + return false + } + return a.Contains(b.IP) || b.Contains(a.IP) +} diff --git a/internal/api/devices.go b/internal/api/devices.go new file mode 100644 index 0000000..bfb14bb --- /dev/null +++ b/internal/api/devices.go @@ -0,0 +1,193 @@ +package api + +import ( + "context" + "time" +) + +// DeviceView is the wire shape returned by the device endpoints. +type DeviceView struct { + ID string `json:"id"` + Name string `json:"name"` + ClientIP string `json:"client_ip"` + Pubkey string `json:"pubkey"` + Hostname string `json:"hostname,omitempty"` + OS string `json:"os,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` +} + +// DeviceCreateReq is the body for POST /v1/devices. +type DeviceCreateReq struct { + Name string `json:"name"` + Pubkey string `json:"pubkey"` // base64 32-byte Curve25519 + Hostname string `json:"hostname,omitempty"` + OS string `json:"os,omitempty"` +} + +// DeviceNetworkAttachmentView is one row of GET /v1/devices/:id/networks. +type DeviceNetworkAttachmentView struct { + NetworkID string `json:"network_id"` + NetworkName string `json:"network_name"` + AttachedAt time.Time `json:"attached_at"` +} + +// DeviceSessionView is the response from POST /v1/devices/:id/sessions. +// ClientConfig is the full wg-quick .conf body the caller writes to disk +// (after prepending its locally-held PrivateKey). +type DeviceSessionView struct { + SessionID string `json:"session_id"` + DeviceID string `json:"device_id"` + RelayHostID string `json:"relay_host_id"` + ClientConfig string `json:"client_config"` + ExpiresAt time.Time `json:"expires_at"` +} + +// CreateDevice registers a machine. Pubkey is required; the CLI keeps +// the matching private key on disk (never shipped to the server). +func (c *SandboxClient) CreateDevice(ctx context.Context, req DeviceCreateReq) (*DeviceView, error) { + var envelope Response[DeviceView] + resp, err := c.Client.R().SetContext(ctx).SetBody(req).SetResult(&envelope).Post("/v1/devices") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// DeviceList is the inner shape under data for GET /v1/devices. +type DeviceList struct { + Data []DeviceView `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// ListDevices returns the caller's registered devices. +func (c *SandboxClient) ListDevices(ctx context.Context) ([]DeviceView, error) { + var envelope Response[DeviceList] + resp, err := c.Client.R().SetContext(ctx).SetResult(&envelope).Get("/v1/devices") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Data, nil +} + +// GetDevice fetches one device by id. +func (c *SandboxClient) GetDevice(ctx context.Context, id string) (*DeviceView, error) { + var envelope Response[DeviceView] + resp, err := c.Client.R().SetContext(ctx).SetPathParam("id", id). + SetResult(&envelope).Get("/v1/devices/{id}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// DeleteDevice revokes a device. All sessions and network attachments +// are removed atomically server-side. +func (c *SandboxClient) DeleteDevice(ctx context.Context, id string) error { + resp, err := c.Client.R().SetContext(ctx).SetPathParam("id", id).Delete("/v1/devices/{id}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// AttachDeviceToNetwork persists a device ↔ network link. +func (c *SandboxClient) AttachDeviceToNetwork(ctx context.Context, deviceID, networkRef string) error { + resp, err := c.Client.R().SetContext(ctx).SetPathParam("id", deviceID). + SetBody(map[string]string{"network_id": networkRef}). + Post("/v1/devices/{id}/networks") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// ListDeviceNetworks returns the networks a device is attached to. +func (c *SandboxClient) ListDeviceNetworks(ctx context.Context, deviceID string) ([]DeviceNetworkAttachmentView, error) { + var envelope Response[[]DeviceNetworkAttachmentView] + resp, err := c.Client.R().SetContext(ctx).SetPathParam("id", deviceID). + SetResult(&envelope).Get("/v1/devices/{id}/networks") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data, nil +} + +// DetachDeviceFromNetwork removes one attachment. +func (c *SandboxClient) DetachDeviceFromNetwork(ctx context.Context, deviceID, networkRef string) error { + resp, err := c.Client.R().SetContext(ctx). + SetPathParam("id", deviceID).SetPathParam("nid", networkRef). + Delete("/v1/devices/{id}/networks/{nid}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// CreateDeviceSession opens a new VPN session for the device and returns +// the wg-quick config the caller should bring up. +func (c *SandboxClient) CreateDeviceSession(ctx context.Context, deviceID string) (*DeviceSessionView, error) { + var envelope Response[DeviceSessionView] + resp, err := c.Client.R().SetContext(ctx).SetPathParam("id", deviceID). + SetResult(&envelope).Post("/v1/devices/{id}/sessions") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// DeleteDeviceSession closes one session. +func (c *SandboxClient) DeleteDeviceSession(ctx context.Context, deviceID, sessionID string) error { + resp, err := c.Client.R().SetContext(ctx). + SetPathParam("id", deviceID).SetPathParam("sid", sessionID). + Delete("/v1/devices/{id}/sessions/{sid}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// RenewDeviceSession bumps the session's expires_at forward by one +// server-side TTL. The CLI's renewal goroutine calls this every ~TTL/2 +// while a tunnel is live. A 404 from this endpoint means the session +// has expired or been deleted server-side — the caller's local WG iface +// is now orphan and must be torn down. Use IsNotFound to discriminate. +func (c *SandboxClient) RenewDeviceSession(ctx context.Context, deviceID, sessionID string) error { + resp, err := c.Client.R().SetContext(ctx). + SetPathParam("id", deviceID).SetPathParam("sid", sessionID). + Put("/v1/devices/{id}/sessions/{sid}") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} diff --git a/internal/api/types.go b/internal/api/types.go index ae2cf5b..8f0bbf8 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "fmt" "net/http" "sort" @@ -18,6 +19,17 @@ func (e *APIError) Error() string { return e.Message } +// IsNotFound reports whether err is an APIError wrapping a 404. +// Used by the VPN renewal loop to discriminate "server lost my session" +// (tear-down signal) from a transient transport failure (retry). +func IsNotFound(err error) bool { + var ae *APIError + if errors.As(err, &ae) { + return ae.StatusCode == http.StatusNotFound + } + return false +} + // Hint returns a contextual suggestion based on the HTTP status code. func (e *APIError) Hint() string { switch e.StatusCode {