diff --git a/.github/workflows/k3s-ca.yml b/.github/workflows/k3s-ca.yml index 445e39d..d76d628 100644 --- a/.github/workflows/k3s-ca.yml +++ b/.github/workflows/k3s-ca.yml @@ -29,14 +29,14 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: shellcheck run: | shellcheck -s sh -S warning pkg/templates/scripts/tn-k3s-ca.sh - name: go test run: | - go test ./test/k3s-ca/... ./cmd/... + go test ./test/k3s-ca/... ./cmd/... ./pkg/auth/... (cd pkg/templates && go test ./...) docker-matrix: @@ -47,7 +47,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: run the harness env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0eca0c..50269ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '1.25' + go-version: '1.26' cache: true - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/README.md b/README.md index 81a748a..73f4147 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,11 @@ Securely connect to your edge devices via Teknoir's proxy infrastructure. ```bash tnctl ssh [device-name] ``` + Or through the WireGuard (ZTNA) tunnel, which the platform can cut off at once if the device is + flagged compromised or fails its health check: + ```bash + tnctl ssh --wireguard [device-name] + ``` - **Port Forwarding**: Forward a local port to the device or its network: ```bash diff --git a/cmd/root.go b/cmd/root.go index e25fd68..065d502 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "path/filepath" @@ -34,6 +35,10 @@ device management, and namespace-scoped operations.`, func Execute() { err := rootCmd.Execute() if err != nil { + var ee *exitError + if errors.As(err, &ee) { + os.Exit(ee.code) + } os.Exit(1) } } diff --git a/cmd/ssh.go b/cmd/ssh.go index f3378ce..fc3263c 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -13,10 +13,20 @@ import ( "github.com/spf13/cobra" ) +var ( + sshWireGuard bool + sshTURNServer string + sshWGHubURL string +) + var sshCmd = &cobra.Command{ Use: "ssh [device-name]", Short: "SSH into a device", RunE: func(cmd *cobra.Command, args []string) error { + if !sshWireGuard && (sshTURNServer != "" || sshWGHubURL != "") { + return fmt.Errorf("--turn-server and --wg-hub-url require --wireguard") + } + cfg, err := config.Load() if err != nil { return err @@ -62,29 +72,22 @@ var sshCmd = &cobra.Command{ return err } - if !device.Spec.Subresources.Status.RemoteAccess.Active { - return fmt.Errorf("remote access is not enabled for device %s. Please enable it in the Teknoir Console", deviceName) + if sshWireGuard { + // The WireGuard path does not use the SSH tunnel's remote_access + // switch; access is decided by the hub (see ssh_wg.go). + // Errors from here on are runtime failures (a hub denial, ssh's exit + // status), not usage mistakes: don't print the usage text. + cmd.SilenceUsage = true + return runSSHWireGuard(domain, namespace, deviceName, device) } - // Decode base64 username, password and private key - usernameBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Username) - if err != nil { - return fmt.Errorf("failed to decode username: %w", err) - } - username := string(usernameBytes) - - var password string - if device.Spec.Settings.Password != "" { - passwordBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Password) - if err != nil { - return fmt.Errorf("failed to decode password: %w", err) - } - password = string(passwordBytes) + if !device.Spec.Subresources.Status.RemoteAccess.Active { + return fmt.Errorf("remote access is not enabled for device %s. Please enable it in the Teknoir Console", deviceName) } - privateKey, err := base64.StdEncoding.DecodeString(device.Spec.Settings.RSAPrivate) + username, password, privateKey, err := decodeDeviceLogin(device) if err != nil { - return fmt.Errorf("failed to decode RSA private key: %w", err) + return err } // Create secure temp file for private key @@ -123,18 +126,52 @@ var sshCmd = &cobra.Command{ fmt.Printf("DEBUG: Executing command: %s %v\n", sshExec.Path, sshExec.Args) } - // Print colorful message and password - fmt.Printf("\033[1;32m\n>>> You are now using a remote shell on device: %s\033[0m\n", deviceName) - if password != "" { - fmt.Printf("\033[1;36m>>> SSH Password: %s\033[0m\n\n", password) - } else { - fmt.Println() - } + printRemoteShellBanner(deviceName, password) return sshExec.Run() }, } +// decodeDeviceLogin decodes the base64 username, password and RSA private key +// from the device details. password is empty when the device has none. +func decodeDeviceLogin(device *api.BackstageDevice) (username, password string, privateKey []byte, err error) { + usernameBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Username) + if err != nil { + return "", "", nil, fmt.Errorf("failed to decode username: %w", err) + } + username = string(usernameBytes) + + if device.Spec.Settings.Password != "" { + passwordBytes, err := base64.StdEncoding.DecodeString(device.Spec.Settings.Password) + if err != nil { + return "", "", nil, fmt.Errorf("failed to decode password: %w", err) + } + password = string(passwordBytes) + } + + privateKey, err = base64.StdEncoding.DecodeString(device.Spec.Settings.RSAPrivate) + if err != nil { + return "", "", nil, fmt.Errorf("failed to decode RSA private key: %w", err) + } + return username, password, privateKey, nil +} + +// printRemoteShellBanner prints the colorful message and the device password. +func printRemoteShellBanner(deviceName, password string) { + fmt.Printf("\033[1;32m\n>>> You are now using a remote shell on device: %s\033[0m\n", deviceName) + if password != "" { + fmt.Printf("\033[1;36m>>> SSH Password: %s\033[0m\n\n", password) + } else { + fmt.Println() + } +} + func init() { rootCmd.AddCommand(sshCmd) + sshCmd.Flags().BoolVar(&sshWireGuard, "wireguard", false, + "connect through the WireGuard (ZTNA) tunnel instead of the SSH tunnel") + sshCmd.Flags().StringVar(&sshTURNServer, "turn-server", "", + "with --wireguard: TURN server host:port overriding the hub's (env TNCTL_WG_TURN_SERVER)") + sshCmd.Flags().StringVar(&sshWGHubURL, "wg-hub-url", "", + "with --wireguard: WireGuard hub URL (default https://wg., env TNCTL_WG_HUB)") } diff --git a/cmd/ssh_wg.go b/cmd/ssh_wg.go new file mode 100644 index 0000000..5e20eff --- /dev/null +++ b/cmd/ssh_wg.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "syscall" + + "github.com/teknoir/cli/pkg/api" + "github.com/teknoir/cli/pkg/config" + wgapi "github.com/teknoir/wg-tunnel/pkg/api" +) + +// proxyArgs are the arguments of the `tnctl wg-proxy` ProxyCommand. +type proxyArgs struct { + Exe string // absolute path of this tnctl executable + ConfigFile string // --config, only when the user set it + Domain string + Namespace string + Device string + Port int + KnownHostsFile string + TURNServer string // optional + HubURL string +} + +var shellSafe = regexp.MustCompile(`^[A-Za-z0-9_@+=:,./-]+$`) + +// quoteArg quotes one word for the shell ssh runs a ProxyCommand with: sh on +// unix, cmd.exe on windows. It does NOT escape '%' (see buildProxyCommand). +func quoteArg(s, goos string) string { + if s != "" && shellSafe.MatchString(s) { + return s + } + if goos == "windows" { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// buildProxyCommand returns the value of ssh's ProxyCommand option. ssh +// expands %-tokens (%h, %p, ...) in it, so every literal '%' is doubled. +func buildProxyCommand(a proxyArgs, goos string) string { + words := []string{a.Exe, "wg-proxy"} + if a.ConfigFile != "" { + words = append(words, "--config", a.ConfigFile) + } + words = append(words, + "--domain", a.Domain, + "--namespace", a.Namespace, + "--device", a.Device, + "--port", strconv.Itoa(a.Port), + "--known-hosts-file", a.KnownHostsFile, + ) + if a.TURNServer != "" { + words = append(words, "--turn-server", a.TURNServer) + } + words = append(words, "--hub-url", a.HubURL) + + quoted := make([]string, len(words)) + for i, w := range words { + quoted[i] = quoteArg(w, goos) + } + return strings.ReplaceAll(strings.Join(quoted, " "), "%", "%%") +} + +// buildSSHArgs returns the argument list for the system ssh. The device host +// key is pinned: wg-proxy writes the key the hub vouches for to knownHosts +// before ssh sees any byte, and ssh refuses any other key. +func buildSSHArgs(proxyCommand, knownHosts, hostKeyAlias, keyFile, username, deviceName string) []string { + return []string{ + "-o", "ProxyCommand=" + proxyCommand, + "-o", "UserKnownHostsFile=" + knownHosts, + "-o", "StrictHostKeyChecking=yes", + "-o", "HostKeyAlias=" + hostKeyAlias, + "-o", "ServerAliveInterval=30", + "-o", "IdentitiesOnly=yes", + "-i", keyFile, + username + "@" + deviceName, + } +} + +// validLoginName guards the user@host argument against option injection. +func validLoginName(u string) error { + if u == "" || strings.HasPrefix(u, "-") || strings.ContainsAny(u, "@ \t\r\n\x00") { + return errors.New("the device login name is not usable for ssh") + } + return nil +} + +// runSSHWireGuard is `tnctl ssh --wireguard`: the same device login as the SSH +// tunnel path, carried by a WireGuard session that wg-proxy brokers. +func runSSHWireGuard(domain, ns, deviceName string, dev *api.BackstageDevice) error { + if err := validateWGTarget(ns, deviceName, 22); err != nil { + return err + } + username, password, privateKey, err := decodeDeviceLogin(dev) + if err != nil { + return err + } + if err := validLoginName(username); err != nil { + return err + } + + hubURL := resolveWGOption(sshWGHubURL, envWGHub, defaultHubURL(domain)) + if err := validateHubURL(hubURL); err != nil { + return err + } + if err := validateHubHost(hubURL, domain); err != nil { + return err + } + turn := resolveWGOption(sshTURNServer, envWGTurnServer, "") + + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("cannot locate the tnctl executable: %w", err) + } + if exe, err = filepath.Abs(exe); err != nil { + return fmt.Errorf("cannot locate the tnctl executable: %w", err) + } + + // 0700 directory with the private key (0600) and the known_hosts file that + // wg-proxy fills in; removed when ssh ends. + tmpDir, err := os.MkdirTemp("", "tnctl-wg-*") + if err != nil { + return fmt.Errorf("failed to create temporary directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + keyFile := filepath.Join(tmpDir, "id") + if err := os.WriteFile(keyFile, privateKey, 0600); err != nil { + return fmt.Errorf("failed to write private key to temporary file: %w", err) + } + knownHosts := filepath.Join(tmpDir, "known_hosts") + + proxy := buildProxyCommand(proxyArgs{ + Exe: exe, ConfigFile: cfgFile, Domain: domain, Namespace: ns, Device: deviceName, Port: 22, + KnownHostsFile: knownHosts, TURNServer: turn, HubURL: hubURL, + }, runtime.GOOS) + + sshExec := exec.Command("ssh", + buildSSHArgs(proxy, knownHosts, wgapi.KnownHostsAlias(ns, deviceName), keyFile, username, deviceName)...) + sshExec.Stdin = os.Stdin + sshExec.Stdout = os.Stdout + sshExec.Stderr = os.Stderr + + if config.Debug { + fmt.Printf("DEBUG: Executing command: %s %v\n", sshExec.Path, sshExec.Args) + } + + printRemoteShellBanner(deviceName, password) + + return runWithSignalForwarding(sshExec) +} + +// runWithSignalForwarding runs cmd, forwarding SIGINT/SIGTERM/SIGHUP to it and +// waiting for it, so the caller's deferred cleanup (the private key directory) +// still runs when tnctl is terminated or its terminal closes. +func runWithSignalForwarding(cmd *exec.Cmd) error { + sigs := make(chan os.Signal, 4) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + defer signal.Stop(sigs) + if err := cmd.Start(); err != nil { + return err + } + done := make(chan struct{}) + go func() { + for { + select { + case sig := <-sigs: + _ = cmd.Process.Signal(sig) + case <-done: + return + } + } + }() + err := cmd.Wait() + close(done) + return err +} diff --git a/cmd/ssh_wg_test.go b/cmd/ssh_wg_test.go new file mode 100644 index 0000000..883950d --- /dev/null +++ b/cmd/ssh_wg_test.go @@ -0,0 +1,376 @@ +package cmd + +import ( + "encoding/base64" + "github.com/teknoir/cli/pkg/api" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "syscall" + "testing" + "time" +) + +func TestQuoteArg(t *testing.T) { + cases := []struct{ in, goos, want string }{ + {"/usr/bin/tnctl", "linux", "/usr/bin/tnctl"}, + {"/tmp/a b/tnctl", "linux", "'/tmp/a b/tnctl'"}, + {"it's", "linux", `'it'\''s'`}, + {"", "linux", "''"}, + {"a;b", "darwin", "'a;b'"}, + {`C:\Program Files\tnctl.exe`, "windows", `"C:\Program Files\tnctl.exe"`}, + {"plain-name.ns", "windows", "plain-name.ns"}, + } + for _, c := range cases { + if got := quoteArg(c.in, c.goos); got != c.want { + t.Errorf("quoteArg(%q,%s) = %s, want %s", c.in, c.goos, got, c.want) + } + } +} + +func TestBuildProxyCommand(t *testing.T) { + a := proxyArgs{ + Exe: "/opt/tn ctl/tnctl", Domain: "teknoir.cloud", Namespace: "ns1", Device: "dev-1", Port: 22, + KnownHostsFile: "/tmp/tnctl-wg-1/known_hosts", HubURL: "https://wg.teknoir.cloud", + } + got := buildProxyCommand(a, "linux") + want := `'/opt/tn ctl/tnctl' wg-proxy --domain teknoir.cloud --namespace ns1 --device dev-1 --port 22 ` + + `--known-hosts-file /tmp/tnctl-wg-1/known_hosts --hub-url https://wg.teknoir.cloud` + if got != want { + t.Fatalf("got %s\nwant %s", got, want) + } + + a.TURNServer = "10.0.0.5:3478" + a.ConfigFile = "/home/u/my conf.yaml" + got = buildProxyCommand(a, "linux") + for _, frag := range []string{"--config '/home/u/my conf.yaml' --domain", "--turn-server 10.0.0.5:3478 --hub-url"} { + if !strings.Contains(got, frag) { + t.Errorf("missing %q in %s", frag, got) + } + } +} + +func TestBuildProxyCommandEscapesPercent(t *testing.T) { + a := proxyArgs{Exe: "/opt/100%/tnctl", Domain: "d", Namespace: "n", Device: "x", Port: 22, KnownHostsFile: "/tmp/%h/kh", HubURL: "https://wg.d"} + got := buildProxyCommand(a, "linux") + if strings.Contains(strings.ReplaceAll(got, "%%", ""), "%") { + t.Fatalf("unescaped %% in %s", got) + } + if !strings.Contains(got, "/opt/100%%/tnctl") || !strings.Contains(got, "/tmp/%%h/kh") { + t.Fatalf("percent not doubled: %s", got) + } +} + +// What ssh does with the option: expand %% to %, then hand it to sh -c. The +// shell must see exactly the words we meant, whatever the paths contain. +func TestBuildProxyCommandShellRoundTrip(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("needs sh") + } + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no sh") + } + a := proxyArgs{ + Exe: "/opt/we ird/it's 100%/tnctl", ConfigFile: `/c/$HOME "x" y.yaml`, Domain: "teknoir.cloud", Namespace: "ns1", Device: "dev-1", Port: 22, + KnownHostsFile: "/tmp/a b/kh;rm -rf x", TURNServer: "h:1", HubURL: "https://wg.teknoir.cloud", + } + cmdline := strings.ReplaceAll(buildProxyCommand(a, "linux"), "%%", "%") + out, err := exec.Command(sh, "-c", `printf '%s\n' `+strings.TrimPrefix(cmdline, "")).Output() + if err != nil { + t.Fatal(err) + } + got := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") + want := []string{ + a.Exe, "wg-proxy", "--config", a.ConfigFile, "--domain", "teknoir.cloud", "--namespace", "ns1", "--device", "dev-1", + "--port", "22", "--known-hosts-file", a.KnownHostsFile, "--turn-server", "h:1", "--hub-url", "https://wg.teknoir.cloud", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("shell saw\n%q\nwant\n%q", got, want) + } +} + +func TestBuildSSHArgs(t *testing.T) { + got := buildSSHArgs("PROXY", "/t/known_hosts", "dev-1.ns1.ztna.teknoir", "/t/id", "teknoir", "dev-1") + want := []string{ + "-o", "ProxyCommand=PROXY", + "-o", "UserKnownHostsFile=/t/known_hosts", + "-o", "StrictHostKeyChecking=yes", + "-o", "HostKeyAlias=dev-1.ns1.ztna.teknoir", + "-o", "ServerAliveInterval=30", + "-o", "IdentitiesOnly=yes", + "-i", "/t/id", + "teknoir@dev-1", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %q", got) + } +} + +func TestValidLoginName(t *testing.T) { + for _, ok := range []string{"teknoir", "pi", "a.b_c-d"} { + if err := validLoginName(ok); err != nil { + t.Errorf("%q: %v", ok, err) + } + } + for _, bad := range []string{"", "-oProxyCommand=x", "a b", "a@b", "a\nb"} { + if err := validLoginName(bad); err == nil { + t.Errorf("%q accepted", bad) + } + } +} + +func TestResolveWGOptionPrecedence(t *testing.T) { + const env = "TNCTL_WG_TEST_OPT" + t.Setenv(env, "") + if got := resolveWGOption("", env, "def"); got != "def" { + t.Errorf("default: %q", got) + } + t.Setenv(env, "fromenv") + if got := resolveWGOption("", env, "def"); got != "fromenv" { + t.Errorf("env over default: %q", got) + } + if got := resolveWGOption("fromflag", env, "def"); got != "fromflag" { + t.Errorf("flag over env: %q", got) + } +} + +func TestDefaultHubURL(t *testing.T) { + if got := defaultHubURL("teknoir.cloud"); got != "https://wg.teknoir.cloud" { + t.Fatal(got) + } +} + +func TestValidateHubURL(t *testing.T) { + good := []string{"https://wg.teknoir.cloud", "https://wg.example:8443", "http://127.0.0.1:8080", "http://localhost:80", "http://[::1]:8080"} + for _, u := range good { + if err := validateHubURL(u); err != nil { + t.Errorf("%s: %v", u, err) + } + } + bad := []string{"", "wg.teknoir.cloud", "http://wg.teknoir.cloud", "ftp://x", "https://u:p@wg.x", "https://wg.x?a=b", "https://"} + for _, u := range bad { + if err := validateHubURL(u); err == nil { + t.Errorf("%q accepted", u) + } + } +} + +func TestSSHWireGuardFlags(t *testing.T) { + f := sshCmd.Flags().Lookup("wireguard") + if f == nil || f.DefValue != "false" { + t.Fatalf("--wireguard missing or default not false: %+v", f) + } + for _, name := range []string{"turn-server", "wg-hub-url"} { + if sshCmd.Flags().Lookup(name) == nil { + t.Errorf("--%s missing on ssh", name) + } + } + // local to ssh only + for _, c := range rootCmd.Commands() { + if c == sshCmd || c.Name() == "wg-proxy" { + continue + } + if c.Flags().Lookup("wireguard") != nil { + t.Errorf("--wireguard leaked into %s", c.Name()) + } + } + if rootCmd.PersistentFlags().Lookup("wireguard") != nil { + t.Error("--wireguard is persistent") + } +} + +// runSSHWireGuard with a fake `ssh` on PATH: checks the argument list, the key +// file mode while ssh runs, and that the temp directory is gone afterwards. +func TestRunSSHWireGuardWithFakeSSH(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("needs sh") + } + bin := t.TempDir() + out := filepath.Join(t.TempDir(), "out") + script := "#!/bin/sh\n" + + "for a in \"$@\"; do printf '%s\\n' \"$a\"; done > " + out + ".args\n" + + "for last; do :; done\n" + + "while [ $# -gt 0 ]; do if [ \"$1\" = -i ]; then key=$2; fi; shift; done\n" + + "stat -c '%a' \"$key\" > " + out + ".mode 2>/dev/null || stat -f '%Lp' \"$key\" > " + out + ".mode\n" + + "cat \"$key\" > " + out + ".key\n" + + "echo \"$key\" > " + out + ".keypath\n" + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(envWGHub, "") + t.Setenv(envWGTurnServer, "") + + dev := &api.BackstageDevice{} + dev.Spec.Settings.Username = base64.StdEncoding.EncodeToString([]byte("teknoir")) + dev.Spec.Settings.RSAPrivate = base64.StdEncoding.EncodeToString([]byte("PRIVATE-KEY-DATA")) + + oldTurn, oldHub := sshTURNServer, sshWGHubURL + sshTURNServer, sshWGHubURL = "10.1.2.3:3478", "" + defer func() { sshTURNServer, sshWGHubURL = oldTurn, oldHub }() + + if err := runSSHWireGuard("teknoir.cloud", "ns1", "dev-1", dev); err != nil { + t.Fatal(err) + } + + argsRaw, err := os.ReadFile(out + ".args") + if err != nil { + t.Fatal(err) + } + args := strings.Split(strings.TrimSuffix(string(argsRaw), "\n"), "\n") + join := strings.Join(args, "\x00") + for _, want := range []string{ + "StrictHostKeyChecking=yes", "HostKeyAlias=dev-1.ns1.ztna.teknoir", "ServerAliveInterval=30", "teknoir@dev-1", + } { + if !strings.Contains(join, want) { + t.Errorf("ssh args lack %q: %q", want, args) + } + } + var proxy, kh string + for _, a := range args { + if v, ok := strings.CutPrefix(a, "ProxyCommand="); ok { + proxy = v + } + if v, ok := strings.CutPrefix(a, "UserKnownHostsFile="); ok { + kh = v + } + } + if !strings.Contains(proxy, "wg-proxy --domain teknoir.cloud --namespace ns1 --device dev-1 --port 22 --known-hosts-file") || + !strings.Contains(proxy, "--turn-server 10.1.2.3:3478 --hub-url https://wg.teknoir.cloud") { + t.Errorf("proxy command %q", proxy) + } + if mode := strings.TrimSpace(readFile(t, out+".mode")); mode != "600" { + t.Errorf("key mode %s", mode) + } + if readFile(t, out+".key") != "PRIVATE-KEY-DATA" { + t.Error("key content") + } + keyPath := strings.TrimSpace(readFile(t, out+".keypath")) + if _, err := os.Stat(keyPath); !os.IsNotExist(err) { + t.Errorf("temp key not removed: %v", err) + } + if _, err := os.Stat(filepath.Dir(kh)); !os.IsNotExist(err) { + t.Errorf("temp dir not removed: %v", err) + } +} + +func TestRunSSHWireGuardRejectsBadInput(t *testing.T) { + dev := &api.BackstageDevice{} + dev.Spec.Settings.Username = base64.StdEncoding.EncodeToString([]byte("-oProxyCommand=x")) + dev.Spec.Settings.RSAPrivate = base64.StdEncoding.EncodeToString([]byte("k")) + if err := runSSHWireGuard("teknoir.cloud", "ns1", "dev-1", dev); err == nil { + t.Error("option-like login accepted") + } + dev.Spec.Settings.Username = base64.StdEncoding.EncodeToString([]byte("ok")) + if err := runSSHWireGuard("teknoir.cloud", "ns1", "-bad", dev); err == nil { + t.Error("bad device name accepted") + } + t.Setenv(envWGHub, "http://wg.teknoir.cloud") + if err := runSSHWireGuard("teknoir.cloud", "ns1", "dev-1", dev); err == nil { + t.Error("plain http hub accepted") + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestValidateHubHost(t *testing.T) { + ok := []string{"https://wg.teknoir.cloud", "https://teknoir.cloud", "https://WG.Teknoir.Cloud.:8443", "http://127.0.0.1:1", "http://localhost", "http://[::1]:9"} + for _, u := range ok { + if err := validateHubHost(u, "teknoir.cloud"); err != nil { + t.Errorf("%s: %v", u, err) + } + } + bad := []string{"https://wg.evil.example", "https://evilteknoir.cloud", "https://teknoir.cloud.evil.example", "https://wg.teknoir.online"} + for _, u := range bad { + if err := validateHubHost(u, "teknoir.cloud"); err == nil { + t.Errorf("%s accepted", u) + } + } + if err := validateHubHost("https://wg.teknoir.cloud", ""); err == nil { + t.Error("empty domain accepted") + } +} + +func TestRunSSHWireGuardRefusesForeignHub(t *testing.T) { + dev := &api.BackstageDevice{} + dev.Spec.Settings.Username = base64.StdEncoding.EncodeToString([]byte("teknoir")) + dev.Spec.Settings.RSAPrivate = base64.StdEncoding.EncodeToString([]byte("k")) + old := sshWGHubURL + sshWGHubURL = "https://wg.evil.example" + defer func() { sshWGHubURL = old }() + if err := runSSHWireGuard("teknoir.cloud", "ns1", "dev-1", dev); err == nil || !strings.Contains(err.Error(), "outside the logged-in domain") { + t.Fatalf("err = %v", err) + } +} + +// SIGTERM to tnctl while ssh runs: ssh gets it, and the key directory is removed. +func TestRunSSHWireGuardCleansUpOnSIGTERM(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("needs sh") + } + bin := t.TempDir() + out := filepath.Join(t.TempDir(), "out") + script := "#!/bin/sh\n" + + "while [ $# -gt 0 ]; do if [ \"$1\" = -i ]; then key=$2; fi; shift; done\n" + + "echo \"$key\" > " + out + ".keypath\n" + + "trap 'exit 7' TERM\n" + + "while :; do sleep 0.05; done\n" + if err := os.WriteFile(filepath.Join(bin, "ssh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(envWGHub, "") + dev := &api.BackstageDevice{} + dev.Spec.Settings.Username = base64.StdEncoding.EncodeToString([]byte("teknoir")) + dev.Spec.Settings.RSAPrivate = base64.StdEncoding.EncodeToString([]byte("PRIVATE")) + old := sshWGHubURL + sshWGHubURL = "" + defer func() { sshWGHubURL = old }() + + done := make(chan error, 1) + go func() { done <- runSSHWireGuard("teknoir.cloud", "ns1", "dev-1", dev) }() + var keyPath string + for i := 0; i < 200 && keyPath == ""; i++ { + time.Sleep(25 * time.Millisecond) + if b, err := os.ReadFile(out + ".keypath"); err == nil { + keyPath = strings.TrimSpace(string(b)) + } + } + if keyPath == "" { + t.Fatal("fake ssh never started") + } + time.Sleep(100 * time.Millisecond) // let the trap install + if err := selfSignal(syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil { + t.Error("expected ssh exit status 7") + } + case <-time.After(10 * time.Second): + t.Fatal("runSSHWireGuard did not return") + } + if _, err := os.Stat(keyPath); !os.IsNotExist(err) { + t.Errorf("key not removed after SIGTERM: %v", err) + } +} + +func selfSignal(sig os.Signal) error { + p, err := os.FindProcess(os.Getpid()) + if err != nil { + return err + } + return p.Signal(sig) +} diff --git a/cmd/wg_common.go b/cmd/wg_common.go new file mode 100644 index 0000000..9ad6697 --- /dev/null +++ b/cmd/wg_common.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "errors" + "fmt" + "net" + "net/url" + "os" + "regexp" + "strings" + + "github.com/teknoir/wg-tunnel/pkg/api" + "github.com/teknoir/wg-tunnel/pkg/knownhosts" +) + +const ( + envWGTurnServer = "TNCTL_WG_TURN_SERVER" + envWGHub = "TNCTL_WG_HUB" +) + +// nameRE is a DNS-style name (namespace, device). +var nameRE = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]{0,251}[a-z0-9])?$`) + +// resolveWGOption returns the first non-empty of: the flag value, the +// environment variable, the default. +func resolveWGOption(flagValue, envName, def string) string { + if flagValue != "" { + return flagValue + } + if v := strings.TrimSpace(os.Getenv(envName)); v != "" { + return v + } + return def +} + +// defaultHubURL is where the hub serves the user session API. +func defaultHubURL(domain string) string { + return "https://" + api.UserHostPrefix + "." + domain +} + +// validateHubURL requires https (the bearer token travels in the request) +// except for a loopback host, which is for local tests. +func validateHubURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return errors.New("the hub URL is not a valid URL") + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return errors.New("the hub URL must not contain credentials, a query or a fragment") + } + switch u.Scheme { + case "https": + return nil + case "http": + host := u.Hostname() + if host == "localhost" { + return nil + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return nil + } + return errors.New("the hub URL must use https (plain http is only allowed for localhost)") + } + return errors.New("the hub URL must use https") +} + +// validateWGTarget checks the (namespace, device, port) triple. +func validateWGTarget(namespace, device string, port int) error { + switch { + case !nameRE.MatchString(namespace): + return errors.New("--namespace is required and must be a DNS-style name") + case !nameRE.MatchString(device): + return errors.New("--device is required and must be a DNS-style name") + case port < 1 || port > 65535: + return errors.New("--port must be between 1 and 65535") + } + if err := knownhosts.ValidAlias(api.KnownHostsAlias(namespace, device)); err != nil { + return fmt.Errorf("namespace/device: %w", err) + } + return nil +} + +// validateHubHost refuses a hub outside the logged-in domain: the Keycloak +// access token for the domain must not be sent to an arbitrary host (a +// poisoned TNCTL_WG_HUB or a pasted command line). Loopback is allowed for tests. +func validateHubHost(raw, domain string) error { + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + return errors.New("the hub URL is not a valid URL") + } + host := strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")) + if host == "localhost" { + return nil + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return nil + } + d := strings.ToLower(strings.Trim(domain, ".")) + if d != "" && (host == d || strings.HasSuffix(host, "."+d)) { + return nil + } + return fmt.Errorf("the hub host %q is outside the logged-in domain %q; refusing to send your login token there", host, domain) +} diff --git a/cmd/wg_proxy.go b/cmd/wg_proxy.go new file mode 100644 index 0000000..87fe7c5 --- /dev/null +++ b/cmd/wg_proxy.go @@ -0,0 +1,337 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "runtime" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "github.com/teknoir/cli/pkg/auth" + "github.com/teknoir/cli/pkg/config" + "github.com/teknoir/wg-tunnel/pkg/api" + "github.com/teknoir/wg-tunnel/pkg/knownhosts" + "github.com/teknoir/wg-tunnel/pkg/wgclient" +) + +// Version is the tnctl version, set from main (goreleaser ldflags). +var Version = "dev" + +const ( + wgExitOK = 0 + wgExitFail = 1 + wgExitUsage = 2 + + wgCreateTimeout = 40 * time.Second + wgDialTimeout = 15 * time.Second + wgDrainGrace = 5 * time.Second +) + +// exitError makes Execute exit with a code without cobra printing anything. +type exitError struct{ code int } + +func (e *exitError) Error() string { return fmt.Sprintf("exit status %d", e.code) } + +type wgProxyOptions struct { + domain string + namespace string + device string + port int + knownHostsFile string + turnServer string + hubURL string + direct bool +} + +var wgProxyOpts wgProxyOptions + +// wgProxyCmd is the ssh ProxyCommand behind `tnctl ssh --wireguard`. It is not +// meant to be run by hand: stdin/stdout carry the raw SSH byte stream. +var wgProxyCmd = &cobra.Command{ + Use: "wg-proxy", + Short: "ssh ProxyCommand that reaches a device through the WireGuard tunnel (used by 'ssh --wireguard')", + Hidden: true, + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + o := wgProxyOpts + // --domain is the root's persistent flag; Load resolves flag, config, default. + cfg, lerr := config.Load() + if lerr != nil { + wgLine(os.Stderr, "cannot read the tnctl configuration: %s", oneLine(lerr.Error())) + return &exitError{code: wgExitUsage} + } + o.domain = cfg.Domain + if o.hubURL == "" { + o.hubURL = resolveWGOption("", envWGHub, "") + } + if o.turnServer == "" { + o.turnServer = resolveWGOption("", envWGTurnServer, "") + } + if o.hubURL == "" && o.domain != "" { + o.hubURL = defaultHubURL(o.domain) + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGTERM, syscall.SIGINT) + defer stop() + + // The token is read from ~/.tnctl.yaml (and refreshed) on every call, + // never from argv or the environment. Session creation may use the + // cached token; every renewal must present a newly issued one (the hub + // refuses a token it has already accepted), so renewals force a refresh. + var calls int + cred := func(ctx context.Context, _ string) (string, error) { + calls++ + if calls == 1 { + return auth.AccessToken(ctx, o.domain) + } + return auth.FreshAccessToken(ctx, o.domain) + } + code := runWGProxy(ctx, o, cred, nil, os.Stdin, os.Stdout, os.Stderr) + if code != wgExitOK { + return &exitError{code: code} + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(wgProxyCmd) + // Local flags: they shadow the root's persistent --namespace/--device on + // purpose, so nothing is taken from the config or environment by accident. + f := wgProxyCmd.Flags() + f.StringVar(&wgProxyOpts.namespace, "namespace", "", "teamspace (namespace) of the device") + f.StringVar(&wgProxyOpts.device, "device", "", "device name") + f.IntVar(&wgProxyOpts.port, "port", 22, "device TCP port") + f.StringVar(&wgProxyOpts.knownHostsFile, "known-hosts-file", "", "file to write the device SSH host keys to") + f.StringVar(&wgProxyOpts.turnServer, "turn-server", "", "TURN server host:port overriding the hub's (env TNCTL_WG_TURN_SERVER)") + f.StringVar(&wgProxyOpts.hubURL, "hub-url", "", "WireGuard hub URL (default https://wg., env TNCTL_WG_HUB)") + f.BoolVar(&wgProxyOpts.direct, "direct", false, "debug: send WireGuard straight to the hub over UDP (in-cluster only)") +} + +func (o *wgProxyOptions) validate() error { + if o.domain == "" { + return errors.New("no domain: set --domain or log in first") + } + if err := validateWGTarget(o.namespace, o.device, o.port); err != nil { + return err + } + if o.knownHostsFile == "" { + return errors.New("--known-hosts-file is required") + } + if o.hubURL == "" { + return errors.New("--hub-url is required") + } + if err := validateHubURL(o.hubURL); err != nil { + return err + } + if err := validateHubHost(o.hubURL, o.domain); err != nil { + return err + } + if o.direct && o.turnServer != "" { + return errors.New("--direct and --turn-server are mutually exclusive") + } + return nil +} + +// wgLine prints the one stderr line every failure ends in. +func wgLine(stderr io.Writer, format string, a ...any) { + _, _ = fmt.Fprintf(stderr, "tnctl: wireguard: "+format+"\n", a...) +} + +// wgHint is the human explanation for a hub denial reason. +func wgHint(reason, namespace, device string) string { + switch reason { + case api.ReasonUnauthenticated: + return "your tnctl login was not accepted; run 'tnctl login' and try again" + case api.ReasonForbidden: + return fmt.Sprintf("you need edit/admin access to teamspace %s", namespace) + case api.ReasonDeviceRevoked: + return "device is flagged compromised/health-check-failed in Teknoir" + case api.ReasonDeviceOffline: + return "the device agent is not connected" + case api.ReasonDeviceDisabled: + return fmt.Sprintf("WireGuard is not enabled for device %s; enable it in the Teknoir Console", device) + case api.ReasonDeviceUnknown: + return fmt.Sprintf("device %s is not known to the WireGuard gateway", device) + case api.ReasonNotEnrolled: + return "the device agent has not enrolled with the hub yet" + case api.ReasonReenroll, api.ReasonEnrollClosed, api.ReasonEnrollConflict: + return "the device agent identity needs attention; ask a teamspace admin to reset its enrollment" + case api.ReasonPortNotAllowed: + return "this port is not allowed for the device" + case api.ReasonPosture: + return "this tnctl version is not accepted by the hub; update tnctl" + case api.ReasonSessionLimit: + return "too many open WireGuard sessions; close one and retry" + case api.ReasonSessionMaxAge: + return "the session reached its maximum lifetime; reconnect" + case api.ReasonSessionUnknown, api.ReasonReplay: + return "the session is no longer valid; reconnect" + case api.ReasonRateLimited: + return "too many requests; wait a moment and retry" + case api.ReasonBadRequest: + return "the hub rejected the request; update tnctl" + case api.ReasonInternal: + return "the hub failed; retry shortly" + } + return "access denied" +} + +// describeWGError turns an error from the session machinery into the text +// after "tnctl: wireguard: ". +func describeWGError(err error, o *wgProxyOptions) string { + var he *wgclient.Error + switch { + case errors.As(err, &he): + reason := he.Reason + if reason == "" { + reason = "unknown" + } + msg := wgHint(he.Reason, o.namespace, o.device) + if he.Message != "" && !strings.EqualFold(he.Message, msg) { + msg += " (hub: " + he.Message + ")" + } + return reason + ": " + msg + case errors.Is(err, auth.ErrNotLoggedIn): + return fmt.Sprintf("not logged in to %s; run 'tnctl login'", o.domain) + case errors.Is(err, wgclient.ErrHandshakeTimeout): + return "handshake with the hub timed out; is the TURN relay (UDP) reachable from this network?" + case errors.Is(err, wgclient.ErrExpired): + return "session expired: the hub could not be reached to renew it" + } + return oneLine(err.Error()) +} + +func oneLine(s string) string { + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, s) + if len(s) > 300 { + s = s[:300] + "..." + } + return s +} + +// runWGProxy creates the session, records the device host keys, then relays +// stdin/stdout through the tunnel. It returns the process exit code. hc may be +// nil (default client). +func runWGProxy(ctx context.Context, o wgProxyOptions, cred wgclient.Credential, hc *http.Client, stdin io.Reader, stdout, stderr io.Writer) int { + if err := o.validate(); err != nil { + wgLine(stderr, "%v", err) + return wgExitUsage + } + + host, _ := os.Hostname() + opts := wgclient.Options{ + HubURL: o.hubURL, + Kind: api.KindClient, + Credential: cred, + ClientRequest: &api.ClientSessionRequest{ + Namespace: o.namespace, Device: o.device, Port: o.port, + Posture: api.Posture{Software: "tnctl", Version: Version, OS: runtime.GOOS, Arch: runtime.GOARCH, Hostname: host}, + }, + Direct: o.direct, + TURNServerOverride: o.turnServer, + HTTPClient: hc, + } + if err := opts.Validate(); err != nil { + wgLine(stderr, "%s", oneLine(err.Error())) + return wgExitUsage + } + + cctx, cancel := context.WithTimeout(ctx, wgCreateTimeout) + sess, err := wgclient.Create(cctx, opts) + cancel() + if err != nil { + wgLine(stderr, "%s", describeWGError(err, &o)) + return wgExitFail + } + defer func() { _ = sess.Close() }() + + // Host keys first: ssh must be able to verify the device before any byte flows. + resp := sess.Response() + if len(resp.SSHHostKeys) == 0 { + wgLine(stderr, "the hub returned no SSH host keys for device %s; refusing to connect without host key verification", o.device) + return wgExitFail + } + if err := knownhosts.Write(o.knownHostsFile, api.KnownHostsAlias(o.namespace, o.device), resp.SSHHostKeys); err != nil { + wgLine(stderr, "cannot record the device SSH host keys: %s", oneLine(err.Error())) + return wgExitFail + } + + dctx, dcancel := context.WithTimeout(ctx, wgDialTimeout) + conn, err := sess.Dial(dctx) + dcancel() + if err != nil { + wgLine(stderr, "cannot reach the device through the tunnel: %s", describeWGError(err, &o)) + return wgExitFail + } + defer func() { _ = conn.Close() }() + return relayWGStdio(ctx, sess, conn, stdin, stdout, stderr, &o) +} + +// relayWGStdio copies stdin to conn and conn to stdout until either side ends, +// the session is torn down (for example because the device was revoked), or +// ctx is cancelled. +func relayWGStdio(ctx context.Context, sess *wgclient.Session, conn net.Conn, stdin io.Reader, stdout, stderr io.Writer, o *wgProxyOptions) int { + connDone := make(chan error, 1) // conn -> stdout finished + stdinDone := make(chan struct{}) + go func() { + _, err := io.Copy(stdout, conn) + connDone <- err + }() + go func() { + _, _ = io.Copy(conn, stdin) + if cw, ok := conn.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() // stdin EOF: tell the device we are done sending + } + close(stdinDone) + }() + + sessionEnded := func() int { + if err := sess.Err(); err != nil { + wgLine(stderr, "session ended: %s", describeWGError(err, o)) + return wgExitFail + } + return wgExitOK + } + select { + case err := <-connDone: + select { // a revoked session closes the conn; report why if the hub said so + case <-sess.Done(): + return sessionEnded() + case <-time.After(100 * time.Millisecond): + } + if err != nil { + wgLine(stderr, "connection lost: %s", oneLine(err.Error())) + return wgExitFail + } + return wgExitOK + case <-stdinDone: + // ssh closed its end: let the device finish, but not forever. + select { + case <-connDone: + case <-sess.Done(): + case <-time.After(wgDrainGrace): + case <-ctx.Done(): + } + return wgExitOK + case <-sess.Done(): + return sessionEnded() + case <-ctx.Done(): + return wgExitOK + } +} diff --git a/cmd/wg_proxy_test.go b/cmd/wg_proxy_test.go new file mode 100644 index 0000000..5d89283 --- /dev/null +++ b/cmd/wg_proxy_test.go @@ -0,0 +1,245 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/teknoir/cli/pkg/auth" + "github.com/teknoir/wg-tunnel/pkg/api" +) + +func goodOpts(hub, dir string) wgProxyOptions { + return wgProxyOptions{ + domain: "teknoir.cloud", namespace: "ns1", device: "dev-1", port: 22, + knownHostsFile: filepath.Join(dir, "known_hosts"), hubURL: hub, + } +} + +func TestWGProxyOptionsValidate(t *testing.T) { + if err := (func() error { o := goodOpts("https://wg.teknoir.cloud", t.TempDir()); return o.validate() })(); err != nil { + t.Fatalf("good options rejected: %v", err) + } + cases := map[string]func(*wgProxyOptions){ + "no domain": func(o *wgProxyOptions) { o.domain = "" }, + "no namespace": func(o *wgProxyOptions) { o.namespace = "" }, + "bad namespace": func(o *wgProxyOptions) { o.namespace = "NS 1" }, + "no device": func(o *wgProxyOptions) { o.device = "" }, + "device with option": func(o *wgProxyOptions) { o.device = "-oProxyCommand=x" }, + "port 0": func(o *wgProxyOptions) { o.port = 0 }, + "port too big": func(o *wgProxyOptions) { o.port = 70000 }, + "no known hosts": func(o *wgProxyOptions) { o.knownHostsFile = "" }, + "no hub": func(o *wgProxyOptions) { o.hubURL = "" }, + "http hub": func(o *wgProxyOptions) { o.hubURL = "http://wg.teknoir.cloud" }, + "direct and turn": func(o *wgProxyOptions) { o.direct = true; o.turnServer = "h:1" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + o := goodOpts("https://wg.teknoir.cloud", t.TempDir()) + mutate(&o) + if err := o.validate(); err == nil { + t.Fatal("accepted") + } + }) + } +} + +func TestWGHintCoversEveryReason(t *testing.T) { + reasons := []string{ + api.ReasonUnauthenticated, api.ReasonNotEnrolled, api.ReasonReenroll, api.ReasonEnrollClosed, api.ReasonEnrollConflict, + api.ReasonSessionLimit, api.ReasonReplay, api.ReasonForbidden, api.ReasonDeviceUnknown, api.ReasonDeviceDisabled, + api.ReasonDeviceRevoked, api.ReasonDeviceOffline, api.ReasonPortNotAllowed, api.ReasonPosture, api.ReasonSessionUnknown, + api.ReasonSessionMaxAge, api.ReasonRateLimited, api.ReasonBadRequest, api.ReasonInternal, + } + for _, r := range reasons { + if h := wgHint(r, "ns1", "dev-1"); h == "" || h == "access denied" { + t.Errorf("reason %s has no specific hint (%q)", r, h) + } + } + if got := wgHint("made_up", "ns1", "dev-1"); got != "access denied" { + t.Errorf("unknown reason: %q", got) + } + if got := wgHint(api.ReasonDeviceRevoked, "ns1", "dev-1"); got != "device is flagged compromised/health-check-failed in Teknoir" { + t.Errorf("revoked: %q", got) + } + if got := wgHint(api.ReasonForbidden, "ns1", "dev-1"); got != "you need edit/admin access to teamspace ns1" { + t.Errorf("forbidden: %q", got) + } + if got := wgHint(api.ReasonDeviceOffline, "ns1", "dev-1"); got != "the device agent is not connected" { + t.Errorf("offline: %q", got) + } +} + +func okCred(context.Context, string) (string, error) { return "test-token", nil } + +func TestRunWGProxyDenials(t *testing.T) { + cases := []struct { + name string + status int + body any + wantErr string + }{ + {"revoked", 403, api.Error{Reason: api.ReasonDeviceRevoked}, + "tnctl: wireguard: device_revoked: device is flagged compromised/health-check-failed in Teknoir\n"}, + {"revoked with operator note", 403, api.Error{Reason: api.ReasonDeviceRevoked, Message: "stolen from site 4"}, + "tnctl: wireguard: device_revoked: device is flagged compromised/health-check-failed in Teknoir (hub: stolen from site 4)\n"}, + {"forbidden", 403, api.Error{Reason: api.ReasonForbidden}, + "tnctl: wireguard: forbidden: you need edit/admin access to teamspace ns1\n"}, + {"offline", 409, api.Error{Reason: api.ReasonDeviceOffline}, + "tnctl: wireguard: device_offline: the device agent is not connected\n"}, + {"not enrolled", 409, api.Error{Reason: api.ReasonNotEnrolled}, + "tnctl: wireguard: not_enrolled: the device agent has not enrolled with the hub yet\n"}, + {"401 without body", 401, nil, + "tnctl: wireguard: unauthenticated: your tnctl login was not accepted; run 'tnctl login' and try again\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var calls atomic.Int32 + var gotAuth, gotPath string + var gotReq api.ClientSessionRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + gotAuth, gotPath = r.Header.Get("Authorization"), r.URL.Path + _ = json.NewDecoder(r.Body).Decode(&gotReq) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(c.status) + if c.body != nil { + _ = json.NewEncoder(w).Encode(c.body) + } + })) + defer srv.Close() + + dir := t.TempDir() + o := goodOpts(srv.URL, dir) + var stdout, stderr bytes.Buffer + code := runWGProxy(context.Background(), o, okCred, nil, strings.NewReader("SSH-2.0-x\r\n"), &stdout, &stderr) + + if code != 1 { + t.Errorf("exit code %d, want 1", code) + } + if stderr.String() != c.wantErr { + t.Errorf("stderr\n got %q\nwant %q", stderr.String(), c.wantErr) + } + if stdout.Len() != 0 { + t.Errorf("stdout must stay clean for ssh, got %q", stdout.String()) + } + if strings.Contains(stderr.String(), "test-token") { + t.Error("token leaked to stderr") + } + if _, err := os.Stat(o.knownHostsFile); err == nil { + t.Error("known_hosts written for a denied session") + } + if calls.Load() != 1 || gotPath != api.ClientSessionPath || gotAuth != "Bearer test-token" { + t.Errorf("calls=%d path=%s auth-set=%v", calls.Load(), gotPath, gotAuth == "Bearer test-token") + } + if gotReq.Namespace != "ns1" || gotReq.Device != "dev-1" || gotReq.Port != 22 || + gotReq.Posture.Software != "tnctl" || gotReq.Posture.Version != Version || gotReq.Posture.OS == "" || gotReq.Posture.Arch == "" { + t.Errorf("request = %+v", gotReq) + } + }) + } +} + +func TestRunWGProxyUsageErrors(t *testing.T) { + o := goodOpts("https://wg.teknoir.cloud", t.TempDir()) + o.device = "" + var stdout, stderr bytes.Buffer + code := runWGProxy(context.Background(), o, okCred, nil, strings.NewReader(""), &stdout, &stderr) + if code != 2 { + t.Errorf("exit code %d, want 2", code) + } + if !strings.HasPrefix(stderr.String(), "tnctl: wireguard: --device is required") || strings.Count(stderr.String(), "\n") != 1 { + t.Errorf("stderr %q", stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("stdout %q", stdout.String()) + } +} + +func TestRunWGProxyNotLoggedIn(t *testing.T) { + var hit atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hit.Store(true) })) + defer srv.Close() + cred := func(context.Context, string) (string, error) { + return "", &auth.NotLoggedInError{Domain: "teknoir.cloud"} + } + var stdout, stderr bytes.Buffer + code := runWGProxy(context.Background(), goodOpts(srv.URL, t.TempDir()), cred, nil, strings.NewReader(""), &stdout, &stderr) + if code != 1 || stderr.String() != "tnctl: wireguard: not logged in to teknoir.cloud; run 'tnctl login'\n" { + t.Errorf("code=%d stderr=%q", code, stderr.String()) + } + if hit.Load() { + t.Error("hub contacted without credentials") + } +} + +func TestDescribeWGErrorOther(t *testing.T) { + o := goodOpts("https://x", "") + if got := describeWGError(errors.New("line1\nline2"), &o); got != "line1 line2" { + t.Errorf("got %q", got) + } +} + +func TestWGProxyCommandRegistration(t *testing.T) { + var found bool + for _, c := range rootCmd.Commands() { + if c.Name() == "wg-proxy" { + found = true + if !c.Hidden { + t.Error("wg-proxy must be hidden") + } + for _, f := range []string{"namespace", "device", "port", "known-hosts-file", "turn-server", "hub-url", "direct"} { + if c.Flags().Lookup(f) == nil { + t.Errorf("flag --%s missing", f) + } + } + } + } + if !found { + t.Fatal("wg-proxy not registered") + } +} + +// Through cobra: a bad invocation exits with code 2 via exitError, with the +// one-line message and nothing on stdout. +func TestWGProxyCobraUsageExit(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("USERPROFILE", os.Getenv("HOME")) + r, w, _ := os.Pipe() + oldErr := os.Stderr + os.Stderr = w + rootCmd.SetArgs([]string{"wg-proxy", "--hub-url", "https://wg.teknoir.cloud"}) + err := rootCmd.Execute() + _ = w.Close() + os.Stderr = oldErr + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + rootCmd.SetArgs(nil) + + var ee *exitError + if !errors.As(err, &ee) || ee.code != 2 { + t.Fatalf("err = %v", err) + } + if !strings.Contains(buf.String(), "tnctl: wireguard: --namespace is required") { + t.Errorf("stderr %q", buf.String()) + } +} + +func TestWGProxyRefusesForeignHubAndBadConfig(t *testing.T) { + o := goodOpts("https://wg.evil.example", t.TempDir()) + if err := o.validate(); err == nil || !strings.Contains(err.Error(), "outside the logged-in domain") { + t.Fatalf("err = %v", err) + } + o = goodOpts("http://127.0.0.1:9", t.TempDir()) + if err := o.validate(); err != nil { + t.Fatalf("loopback refused: %v", err) + } +} diff --git a/docs/commands/ssh.md b/docs/commands/ssh.md index b226174..d4cdd98 100644 --- a/docs/commands/ssh.md +++ b/docs/commands/ssh.md @@ -5,7 +5,7 @@ Open an interactive SSH session on an edge device through Teknoir's secure proxy ## Synopsis ```bash -tnctl ssh [device-name] +tnctl ssh [device-name] [--wireguard [--turn-server host:port] [--wg-hub-url url]] ``` The device may be given as an argument, via the `--device` global flag, or selected interactively if @@ -29,6 +29,52 @@ launches `ssh` through the namespace deadend proxy (`deadend-.` | Where to create the WireGuard session. Also `TNCTL_WG_HUB`. Must be `https`. | +| `--turn-server` | the relay the hub hands out | `host:port` that replaces the TURN relay address (for lab networks). Also `TNCTL_WG_TURN_SERVER`. | + +A flag wins over its environment variable, which wins over the default. `--wg-hub-url` and +`--turn-server` are rejected without `--wireguard`. + +Requirements for `--wireguard`: + +- You are logged in (`tnctl login`) and have edit or admin access to the device's teamspace. +- WireGuard is enabled for the device in the Teknoir Console and the device is online and connected. +- Local `ssh` is installed. `ncat` is **not** needed. +- Your network allows outbound HTTPS to the hub and outbound UDP to the TURN relay. +- The Remote Access switch is **not** required: it applies to the SSH tunnel only. + +The device login is unchanged: `tnctl` still uses the device's username and RSA key, and prints the +device password the same way. The device's SSH host key is verified strictly against the key the +device itself reported to the hub. + +### Access is checked by the hub, continuously + +If the device is flagged compromised or fails its health check in Teknoir, the hub refuses new +connections and ends open ones. You see one line from `tnctl`, then `ssh` exits: + +```text +tnctl: wireguard: device_revoked: device is flagged compromised/health-check-failed in Teknoir +``` + +Other refusals use the same form, for example `forbidden` (you need edit/admin access to the +teamspace), `device_offline` (the device agent is not connected), `device_disabled` (WireGuard is not +enabled for the device) or `unauthenticated` (run `tnctl login`). + ## Examples ```bash @@ -40,6 +86,9 @@ tnctl --device edge-01 ssh # Pick interactively tnctl ssh + +# Through the WireGuard tunnel +tnctl ssh --wireguard edge-01 ``` !!! tip @@ -48,4 +97,4 @@ tnctl ssh ## See also - [`port-forward`](port-forward.md), [`socks-proxy`](socks-proxy.md) -- [Remote Access](../concepts/remote-access.md) +- [Remote Access](../concepts/remote-access.md), including the [WireGuard (ZTNA) path](../concepts/remote-access.md#wireguard-ztna-path) diff --git a/docs/concepts/remote-access.md b/docs/concepts/remote-access.md index d7ce461..4ebb402 100644 --- a/docs/concepts/remote-access.md +++ b/docs/concepts/remote-access.md @@ -50,6 +50,41 @@ The SSH options used harden and stabilize the session: `port-forward` additionally recognizes common web ports (80, 443, 3000, 5000, 8080, 8443, 9000, 9090) and prints a ready-to-click `http(s)://localhost:` URL. +## WireGuard (ZTNA) path + +`tnctl ssh --wireguard` reaches the device through a different transport that is built for zero trust +access. It is available for `ssh` only, and it runs next to the SSH tunnel described above. + +How it works: + +1. `tnctl` asks the WireGuard hub (`https://wg.`) for a session to one device and one port + (22), authenticating with your `tnctl login` token. The hub checks your access to the teamspace + and the device's state before it answers. +2. The hub returns a WireGuard session and the device's SSH host keys. `tnctl` brings up a userspace + WireGuard tunnel (no root, no network interface) to the hub, relayed over UDP through a TURN + gateway, so no inbound ports are needed on your side or on the device. +3. `ssh` runs with `ProxyCommand` pointing at `tnctl wg-proxy`, which pipes the SSH stream through the + tunnel. The host keys from step 2 are written to a temporary `known_hosts`, and `ssh` runs with + `StrictHostKeyChecking=yes`: the device must present exactly those keys. +4. The hub splices the stream to the device's own, outbound-connected agent. You never get network + access to the device or its network, only the one port you asked for. + +```mermaid +flowchart LR + L[Local tnctl + ssh] -->|HTTPS: create session| H[WireGuard hub] + L ==>|WireGuard over TURN/UDP| H + D[Device agent] ==>|WireGuard over TURN/UDP| H +``` + +Sessions are short-lived and renewed with a fresh credential every time. The hub re-checks the device +and your access on each renewal. If the device is flagged compromised, fails its health check, or you +lose access, new sessions are refused and open ones are ended at once. `tnctl` prints a single +`tnctl: wireguard: : ` line and exits with status 1. + +Differences from the SSH tunnel path: it does not need Remote Access to be enabled, it does not need +`ncat`, and the SSH host key is verified instead of ignored. See +[`tnctl ssh`](../commands/ssh.md#wireguard-ztna-path-wireguard) for flags and requirements. + ## Security notes - Private keys are **never** written to your project directory — only to a temporary file that is diff --git a/docs/installation.md b/docs/installation.md index 7a078e2..e741320 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -49,7 +49,7 @@ After extracting, move the `tnctl` binary into a directory on your `PATH` (for e ## From source -Requires **Go 1.23+** (this repository builds with Go 1.25). +Requires **Go 1.26+** (this repository builds with Go 1.26). ```bash git clone https://github.com/teknoir/cli.git diff --git a/go.mod b/go.mod index 974a25f..4f128de 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/teknoir/cli -go 1.25.0 +go 1.26.0 require ( github.com/ktr0731/go-fuzzyfinder v0.9.0 github.com/pterm/pterm v0.12.83 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - golang.org/x/oauth2 v0.36.0 + golang.org/x/oauth2 v0.37.0 ) require ( @@ -20,6 +20,7 @@ require ( github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/tcell/v2 v2.6.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/btree v1.1.3 // indirect github.com/gookit/color v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/ktr0731/go-ansisgr v0.1.0 // indirect @@ -28,6 +29,12 @@ require ( github.com/mattn/go-runewidth v0.0.20 // indirect github.com/nsf/termbox-go v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pion/dtls/v3 v3.0.10 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v5 v5.0.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -35,13 +42,23 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/wlynxg/anet v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.40.0 // indirect - golang.org/x/text v0.34.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/net v0.60.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect + gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect ) -require github.com/teknoir/cli/pkg/templates v0.0.0 +require ( + github.com/teknoir/cli/pkg/templates v0.0.0 + github.com/teknoir/wg-tunnel v0.0.0-20261013135421-0331c2849daa +) replace github.com/teknoir/cli/pkg/templates => ./pkg/templates diff --git a/go.sum b/go.sum index 7d41ee9..af05a1d 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,10 @@ github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCyS github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= @@ -77,6 +81,18 @@ github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= +github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v5 v5.0.3 h1:I+Nw0fQgdPWF1SXDj0egWDhCkcff7gWiigdQpOK52Ak= +github.com/pion/turn/v5 v5.0.3/go.mod h1:fs4SogUh/aRGQzonc4Lx3Jp4EU3j3t0PfNDEd9KcD/w= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -120,24 +136,33 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/teknoir/wg-tunnel v0.0.0-20261013135421-0331c2849daa h1:MObefUP/2bzYdR2eHFiFXSSO7uEn6u3XWcn9Xx8msYA= +github.com/teknoir/wg-tunnel v0.0.0-20261013135421-0331c2849daa/go.mod h1:zi/Cgy80eT8qwsRQsGze1sv2Rq/QirE17BUT0gnzwKM= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY= +golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= 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/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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= 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= @@ -152,30 +177,35 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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/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-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc= +golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -183,3 +213,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= +gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= diff --git a/main.go b/main.go index 5ccdf19..b8550b0 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,10 @@ package main import "github.com/teknoir/cli/cmd" +// version is set by goreleaser (-X main.version=...). +var version = "dev" + func main() { + cmd.Version = version cmd.Execute() } diff --git a/pkg/api/devices.go b/pkg/api/devices.go index 9f21b31..7d2a83e 100644 --- a/pkg/api/devices.go +++ b/pkg/api/devices.go @@ -4,10 +4,8 @@ import ( "context" "fmt" "net/url" - "time" "github.com/teknoir/cli/pkg/auth" - "github.com/teknoir/cli/pkg/config" ) type DeviceAlert struct { @@ -75,40 +73,18 @@ type backstageResponse struct { } func FetchDeviceDetails(ctx context.Context, domain, namespace, deviceName string) (*BackstageDevice, error) { - cfg, err := config.Load() + accessToken, err := auth.AccessToken(ctx, domain) if err != nil { return nil, err } - sanitizedDomain := config.SanitizeDomain(domain) - authData, exists := cfg.Auths[sanitizedDomain] - if !exists || authData.AccessToken == "" { - return nil, fmt.Errorf("no credentials found for domain %s. Please log in first", domain) - } - - token, err := auth.GetValidToken(ctx, domain, authData) - if err != nil { - return nil, err - } - - // Update config with new token if refreshed - if token.AccessToken != authData.AccessToken || token.RefreshToken != authData.RefreshToken { - authData.AccessToken = token.AccessToken - authData.RefreshToken = token.RefreshToken - authData.Expiry = token.Expiry.Format(time.RFC3339) - cfg.Auths[sanitizedDomain] = authData - if err := cfg.Save(); err != nil { - return nil, fmt.Errorf("failed to save refreshed token: %w", err) - } - } - url := fmt.Sprintf("https://%s/api/catalog/entities/by-refs", domain) payload := map[string][]string{ "entityRefs": {fmt.Sprintf("resource:%s/%s", namespace, deviceName)}, } var resp backstageResponse - if err := RequestWithBody(ctx, "POST", url, token.AccessToken, payload, &resp); err != nil { + if err := RequestWithBody(ctx, "POST", url, accessToken, payload, &resp); err != nil { return nil, err } @@ -132,33 +108,11 @@ func FetchDevices(ctx context.Context, domain, namespace string) ([]string, erro } func FetchDeviceDetailsList(ctx context.Context, domain, namespace string, labels map[string]string) ([]BackstageDevice, error) { - cfg, err := config.Load() + accessToken, err := auth.AccessToken(ctx, domain) if err != nil { return nil, err } - sanitizedDomain := config.SanitizeDomain(domain) - authData, exists := cfg.Auths[sanitizedDomain] - if !exists || authData.AccessToken == "" { - return nil, fmt.Errorf("no credentials found for domain %s. Please log in first", domain) - } - - token, err := auth.GetValidToken(ctx, domain, authData) - if err != nil { - return nil, err - } - - // Update config with new token if refreshed - if token.AccessToken != authData.AccessToken || token.RefreshToken != authData.RefreshToken { - authData.AccessToken = token.AccessToken - authData.RefreshToken = token.RefreshToken - authData.Expiry = token.Expiry.Format(time.RFC3339) - cfg.Auths[sanitizedDomain] = authData - if err := cfg.Save(); err != nil { - return nil, fmt.Errorf("failed to save refreshed token: %w", err) - } - } - filterStr := fmt.Sprintf("kind=resource,spec.type=device,metadata.namespace=%s", namespace) for k, v := range labels { filterStr += fmt.Sprintf(",metadata.labels.%s=%s", k, v) @@ -167,7 +121,7 @@ func FetchDeviceDetailsList(ctx context.Context, domain, namespace string, label reqURL := fmt.Sprintf("https://%s/api/catalog/entities?filter=%s&order=asc%%3Ametadata.name", domain, url.QueryEscape(filterStr)) var items []BackstageDevice - if err := Request(ctx, "GET", reqURL, token.AccessToken, &items); err != nil { + if err := Request(ctx, "GET", reqURL, accessToken, &items); err != nil { return nil, err } diff --git a/pkg/auth/token.go b/pkg/auth/token.go new file mode 100644 index 0000000..aaf9491 --- /dev/null +++ b/pkg/auth/token.go @@ -0,0 +1,146 @@ +package auth + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/spf13/viper" + + "github.com/teknoir/cli/pkg/config" + "golang.org/x/oauth2" +) + +// ErrNotLoggedIn is matched (errors.Is) by the error AccessToken returns when +// ~/.tnctl.yaml holds no credentials for the domain. +var ErrNotLoggedIn = fmt.Errorf("not logged in") + +// NotLoggedInError reports that there are no stored credentials for a domain. +type NotLoggedInError struct{ Domain string } + +func (e *NotLoggedInError) Error() string { + return fmt.Sprintf("no credentials found for domain %s. Please log in first", e.Domain) +} + +// Is makes errors.Is(err, ErrNotLoggedIn) true. +func (e *NotLoggedInError) Is(target error) bool { return target == ErrNotLoggedIn } + +// tokenDeps are the side effects AccessToken needs, so they can be faked. +type tokenDeps struct { + load func() (*config.Config, error) + refresh func(ctx context.Context, domain string, a config.AuthConfig) (*oauth2.Token, error) + save func(*config.Config) error + // lock serialises the read-refresh-save cycle across processes; nil = none. + lock func(ctx context.Context) (unlock func(), err error) +} + +// tokenMu serialises the cycle inside one process. +var tokenMu sync.Mutex + +// fileLock takes an exclusive lock file next to the config file, so two tnctl +// processes (a long-lived wg-proxy and another command) never run the +// rotating-refresh-token cycle at the same time. A lock older than 30s is +// considered stale (crashed holder) and is taken over. +func fileLock(ctx context.Context) (func(), error) { + path := viper.ConfigFileUsed() + if path == "" { + return func() {}, nil + } + lock := path + ".lock" + deadline := time.Now().Add(15 * time.Second) + for { + f, err := os.OpenFile(lock, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + _ = f.Close() + return func() { _ = os.Remove(lock) }, nil + } + if !os.IsExist(err) { + return func() {}, nil // cannot lock (read-only dir): proceed unlocked + } + if st, serr := os.Stat(lock); serr == nil && time.Since(st.ModTime()) > 30*time.Second { + _ = os.Remove(lock) + continue + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("timed out waiting for the tnctl config lock %s", lock) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(50 * time.Millisecond): + } + } +} + +var defaultTokenDeps = tokenDeps{ + // LoadFresh re-reads the file, so a refresh token rotated by another + // process is never overwritten with a stale one. + load: config.LoadFresh, + refresh: GetValidToken, + save: func(c *config.Config) error { return c.Save() }, + lock: fileLock, +} + +// AccessToken loads the tnctl configuration, returns a valid access token for +// the domain (refreshing it when it is about to expire) and persists a +// refreshed token back to the configuration. It re-reads the configuration on +// every call, so long-running processes always get the current token. +func AccessToken(ctx context.Context, domain string) (string, error) { + return accessToken(ctx, domain, defaultTokenDeps) +} + +// FreshAccessToken is like AccessToken but always redeems the refresh token, +// so the returned access token is newly issued by Keycloak. The WireGuard hub +// requires a newly issued token on every session renewal; a user who was +// disabled or logged out in Keycloak therefore loses the tunnel at the next +// renewal instead of when the old access token expires. +func FreshAccessToken(ctx context.Context, domain string) (string, error) { + d := defaultTokenDeps + refresh := d.refresh + d.refresh = func(ctx context.Context, domain string, a config.AuthConfig) (*oauth2.Token, error) { + a.Expiry = "" // unparsable expiry makes GetValidToken redeem the refresh token + return refresh(ctx, domain, a) + } + return accessToken(ctx, domain, d) +} + +func accessToken(ctx context.Context, domain string, d tokenDeps) (string, error) { + tokenMu.Lock() + defer tokenMu.Unlock() + if d.lock != nil { + unlock, err := d.lock(ctx) + if err != nil { + return "", err + } + defer unlock() + } + cfg, err := d.load() + if err != nil { + return "", err + } + + key := config.SanitizeDomain(domain) + authData, exists := cfg.Auths[key] + if !exists || authData.AccessToken == "" { + return "", &NotLoggedInError{Domain: domain} + } + + token, err := d.refresh(ctx, domain, authData) + if err != nil { + return "", err + } + + // Update config with new token if refreshed + if token.AccessToken != authData.AccessToken || token.RefreshToken != authData.RefreshToken { + authData.AccessToken = token.AccessToken + authData.RefreshToken = token.RefreshToken + authData.Expiry = token.Expiry.Format(time.RFC3339) + cfg.Auths[key] = authData + if err := d.save(cfg); err != nil { + return "", fmt.Errorf("failed to save refreshed token: %w", err) + } + } + return token.AccessToken, nil +} diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go new file mode 100644 index 0000000..ecab662 --- /dev/null +++ b/pkg/auth/token_test.go @@ -0,0 +1,235 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "github.com/spf13/viper" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/teknoir/cli/pkg/config" + "golang.org/x/oauth2" +) + +func cfgWith(domain string, a config.AuthConfig) *config.Config { + return &config.Config{Domain: domain, Auths: map[string]config.AuthConfig{config.SanitizeDomain(domain): a}} +} + +func TestAccessTokenNotLoggedIn(t *testing.T) { + cases := map[string]*config.Config{ + "no entry for the domain": cfgWith("other.example", config.AuthConfig{AccessToken: "x"}), + "entry without token": cfgWith("teknoir.cloud", config.AuthConfig{RefreshToken: "r"}), + } + for name, cfg := range cases { + t.Run(name, func(t *testing.T) { + d := tokenDeps{load: func() (*config.Config, error) { return cfg, nil }} + _, err := accessToken(context.Background(), "teknoir.cloud", d) + if !errors.Is(err, ErrNotLoggedIn) { + t.Fatalf("want ErrNotLoggedIn, got %v", err) + } + if want := "no credentials found for domain teknoir.cloud. Please log in first"; err.Error() != want { + t.Fatalf("message changed: %q", err.Error()) + } + }) + } +} + +func TestAccessTokenUnchangedDoesNotSave(t *testing.T) { + cfg := cfgWith("teknoir.cloud", config.AuthConfig{AccessToken: "at", RefreshToken: "rt"}) + saved := false + d := tokenDeps{ + load: func() (*config.Config, error) { return cfg, nil }, + refresh: func(_ context.Context, domain string, a config.AuthConfig) (*oauth2.Token, error) { + if domain != "teknoir.cloud" { + t.Errorf("domain %q", domain) + } + return &oauth2.Token{AccessToken: a.AccessToken, RefreshToken: a.RefreshToken}, nil + }, + save: func(*config.Config) error { saved = true; return nil }, + } + tok, err := accessToken(context.Background(), "teknoir.cloud", d) + if err != nil || tok != "at" { + t.Fatalf("tok=%q err=%v", tok, err) + } + if saved { + t.Fatal("config saved although the token did not change") + } +} + +func TestAccessTokenRefreshSaves(t *testing.T) { + cfg := cfgWith("teknoir.cloud", config.AuthConfig{AccessToken: "old", RefreshToken: "rt-old", Realm: "teknoir", Namespace: "ns1"}) + exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + var saved *config.Config + d := tokenDeps{ + load: func() (*config.Config, error) { return cfg, nil }, + refresh: func(context.Context, string, config.AuthConfig) (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "new", RefreshToken: "rt-new", Expiry: exp}, nil + }, + save: func(c *config.Config) error { saved = c; return nil }, + } + tok, err := accessToken(context.Background(), "teknoir.cloud", d) + if err != nil || tok != "new" { + t.Fatalf("tok=%q err=%v", tok, err) + } + got := saved.Auths["teknoir_cloud"] + if got.AccessToken != "new" || got.RefreshToken != "rt-new" || got.Expiry != exp.Format(time.RFC3339) { + t.Fatalf("saved auth = %+v", got) + } + if got.Realm != "teknoir" || got.Namespace != "ns1" { + t.Fatalf("other fields lost: %+v", got) + } +} + +func TestAccessTokenErrors(t *testing.T) { + boom := errors.New("boom") + cfg := cfgWith("d.example", config.AuthConfig{AccessToken: "a"}) + load := func() (*config.Config, error) { return cfg, nil } + + if _, err := accessToken(context.Background(), "d.example", tokenDeps{load: func() (*config.Config, error) { return nil, boom }}); !errors.Is(err, boom) { + t.Fatalf("load error lost: %v", err) + } + if _, err := accessToken(context.Background(), "d.example", tokenDeps{load: load, + refresh: func(context.Context, string, config.AuthConfig) (*oauth2.Token, error) { return nil, boom }}); !errors.Is(err, boom) { + t.Fatalf("refresh error lost: %v", err) + } + _, err := accessToken(context.Background(), "d.example", tokenDeps{load: load, + refresh: func(context.Context, string, config.AuthConfig) (*oauth2.Token, error) { + return &oauth2.Token{AccessToken: "n"}, nil + }, + save: func(*config.Config) error { return boom }}) + if !errors.Is(err, boom) || err.Error() != "failed to save refreshed token: boom" { + t.Fatalf("save error: %v", err) + } +} + +// With an unexpired token the real GetValidToken returns without any network access. +func TestAccessTokenUnexpiredNoNetwork(t *testing.T) { + exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + cfg := cfgWith("teknoir.cloud", config.AuthConfig{AccessToken: "live", RefreshToken: "r", Expiry: exp}) + d := tokenDeps{ + load: func() (*config.Config, error) { return cfg, nil }, + refresh: GetValidToken, + save: func(*config.Config) error { t.Fatal("unexpected save"); return nil }, + } + tok, err := accessToken(context.Background(), "teknoir.cloud", d) + if err != nil || tok != "live" { + t.Fatalf("tok=%q err=%v", tok, err) + } +} + +// Concurrent AccessToken calls must not interleave the refresh cycle: each +// refresh sees the token the previous one stored. +func TestAccessTokenSerialisesRefresh(t *testing.T) { + cfg := cfgWith("teknoir.cloud", config.AuthConfig{AccessToken: "t0", RefreshToken: "r0"}) + var inRefresh, maxIn int32 + var mu sync.Mutex + n := 0 + d := tokenDeps{ + load: func() (*config.Config, error) { + mu.Lock() + defer mu.Unlock() + c := *cfg + c.Auths = map[string]config.AuthConfig{} + for k, v := range cfg.Auths { + c.Auths[k] = v + } + return &c, nil + }, + refresh: func(_ context.Context, _ string, a config.AuthConfig) (*oauth2.Token, error) { + if v := atomic.AddInt32(&inRefresh, 1); v > atomic.LoadInt32(&maxIn) { + atomic.StoreInt32(&maxIn, v) + } + time.Sleep(10 * time.Millisecond) + atomic.AddInt32(&inRefresh, -1) + mu.Lock() + n++ + nn := n + mu.Unlock() + return &oauth2.Token{AccessToken: a.AccessToken + "+", RefreshToken: fmt.Sprintf("r%d", nn)}, nil + }, + save: func(c *config.Config) error { + mu.Lock() + cfg = c + mu.Unlock() + return nil + }, + } + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { defer wg.Done(); _, _ = accessToken(context.Background(), "teknoir.cloud", d) }() + } + wg.Wait() + if maxIn != 1 { + t.Fatalf("refresh ran %d at a time", maxIn) + } + if got := cfg.Auths[config.SanitizeDomain("teknoir.cloud")].AccessToken; got != "t0++++++++" { + t.Fatalf("lost update: %q", got) + } +} + +func TestFileLockExcludesAndRecovers(t *testing.T) { + f := filepath.Join(t.TempDir(), ".tnctl.yaml") + if err := os.WriteFile(f, []byte("domain: x\n"), 0o600); err != nil { + t.Fatal(err) + } + viper.SetConfigFile(f) + defer viper.Reset() + un, err := fileLock(context.Background()) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if _, err := fileLock(ctx); err == nil { + t.Fatal("second lock acquired while held") + } + un() + un2, err := fileLock(context.Background()) + if err != nil { + t.Fatal(err) + } + un2() + // stale lock is taken over + _ = os.WriteFile(f+".lock", nil, 0o600) + old := time.Now().Add(-time.Minute) + _ = os.Chtimes(f+".lock", old, old) + un3, err := fileLock(context.Background()) + if err != nil { + t.Fatal(err) + } + un3() +} + +func TestFreshAccessTokenForcesRefresh(t *testing.T) { + var sawExpiry []string + d := tokenDeps{ + load: func() (*config.Config, error) { + return &config.Config{Auths: map[string]config.AuthConfig{ + config.SanitizeDomain("example.com"): {AccessToken: "old", RefreshToken: "r", Expiry: time.Now().Add(time.Hour).Format(time.RFC3339)}, + }}, nil + }, + refresh: func(_ context.Context, _ string, a config.AuthConfig) (*oauth2.Token, error) { + sawExpiry = append(sawExpiry, a.Expiry) + return &oauth2.Token{AccessToken: "new", RefreshToken: "r2", Expiry: time.Now().Add(5 * time.Minute)}, nil + }, + save: func(*config.Config) error { return nil }, + } + refresh := d.refresh + d.refresh = func(ctx context.Context, domain string, a config.AuthConfig) (*oauth2.Token, error) { + a.Expiry = "" + return refresh(ctx, domain, a) + } + tok, err := accessToken(context.Background(), "example.com", d) + if err != nil || tok != "new" { + t.Fatalf("got %q, %v", tok, err) + } + if len(sawExpiry) != 1 || sawExpiry[0] != "" { + t.Fatalf("refresh did not see a cleared expiry: %v", sawExpiry) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 608b269..7f848cf 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -73,7 +73,55 @@ func (c *Config) Save() error { viper.Set("namespace", nil) viper.Set("device", nil) viper.Set("debug", nil) - return viper.WriteConfig() + return writeConfigAtomic() +} + +// writeConfigAtomic writes viper's state next to the config file and renames +// it into place, so a concurrent reader never sees a half-written file. +func writeConfigAtomic() error { + path := viper.ConfigFileUsed() + if path == "" { + return viper.WriteConfig() + } + f, err := os.CreateTemp(filepath.Dir(path), ".tnctl-*"+filepath.Ext(path)) + if err != nil { + return viper.WriteConfig() + } + tmp := f.Name() + _ = f.Close() + defer os.Remove(tmp) + if err := viper.WriteConfigAs(tmp); err != nil { + return err + } + mode := os.FileMode(0o600) + if st, err := os.Stat(path); err == nil { + mode = st.Mode().Perm() + } + _ = os.Chmod(tmp, mode) + return os.Rename(tmp, path) +} + +// LoadFresh reads the configuration file from disk again, ignoring viper's +// in-memory state, for long-running processes that must see token refreshes +// made by other tnctl processes. Without a config file it behaves like Load. +func LoadFresh() (*Config, error) { + path := viper.ConfigFileUsed() + if path == "" { + return Load() + } + v := viper.New() + v.SetConfigFile(path) + if err := v.ReadInConfig(); err != nil { + return nil, err + } + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, err + } + if cfg.Auths == nil { + cfg.Auths = make(map[string]AuthConfig) + } + return &cfg, nil } // UpdateDomain switches the active domain.