From edcb025b09cab3f68806370a0025155d62b3b81c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 12:29:30 -0800 Subject: [PATCH 01/21] basic ssh client implementation --- cmd/root.go | 4 +- cmd/ssh/runner.go | 160 +++++++++++++++++++++++++++++++++++++ cmd/ssh/ssh.go | 53 ++++++++++++ go.mod | 4 +- go.sum | 2 + internal/version/consts.go | 2 +- 6 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 cmd/ssh/runner.go create mode 100644 cmd/ssh/ssh.go diff --git a/cmd/root.go b/cmd/root.go index 5477578..1e37410 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,7 +12,8 @@ import ( "github.com/fosrl/cli/cmd/auth/logout" "github.com/fosrl/cli/cmd/down" "github.com/fosrl/cli/cmd/logs" - selectcmd "github.com/fosrl/cli/cmd/select" + selectcmd "github.com/fosrl/cli/cmd/select" + "github.com/fosrl/cli/cmd/ssh" "github.com/fosrl/cli/cmd/status" "github.com/fosrl/cli/cmd/up" "github.com/fosrl/cli/cmd/update" @@ -47,6 +48,7 @@ func RootCommand(initResources bool) (*cobra.Command, error) { cmd.AddCommand(up.UpCmd()) cmd.AddCommand(down.DownCmd()) cmd.AddCommand(logs.LogsCmd()) + cmd.AddCommand(ssh.SSHCmd()) cmd.AddCommand(status.StatusCmd()) cmd.AddCommand(update.UpdateCmd()) cmd.AddCommand(version.VersionCmd()) diff --git a/cmd/ssh/runner.go b/cmd/ssh/runner.go new file mode 100644 index 0000000..b18898a --- /dev/null +++ b/cmd/ssh/runner.go @@ -0,0 +1,160 @@ +package ssh + +import ( + "errors" + "io" + "os" + "os/exec" + "os/signal" + "runtime" + "syscall" + + "github.com/creack/pty" + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +// sshSearchPaths are fallback locations for the ssh executable when not in PATH. +var sshSearchPaths = []string{ + "/usr/bin/ssh", + "/usr/local/bin/ssh", + `C:\Windows\System32\OpenSSH\ssh.exe`, +} + +func findSSHPath() (string, error) { + if path, err := exec.LookPath("ssh"); err == nil { + return path, nil + } + for _, p := range sshSearchPaths { + if isExecutable(p) { + return p, nil + } + } + return "", errors.New("ssh executable not found in PATH or in common locations") +} + +func isExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if runtime.GOOS == "windows" { + return true + } + return info.Mode()&0o111 != 0 +} + +func exitCodeFromError(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 1 +} + +type RunOpts struct { + User string + Hostname string + Identity string + PassThrough []string +} + +// Run builds the ssh command from opts, runs it (with a PTY when stdin is a terminal on Unix), +// and returns the exit code and any error from starting/waiting. +func Run(opts RunOpts) (int, error) { + sshPath, err := findSSHPath() + if err != nil { + return 1, err + } + + argv := buildSSHArgs(sshPath, opts) + cmd := exec.Command(argv[0], argv[1:]...) + + usePTY := runtime.GOOS != "windows" && isatty.IsTerminal(os.Stdin.Fd()) + + if usePTY { + return runWithPTY(cmd) + } + return runWithoutPTY(cmd) +} + +func buildSSHArgs(sshPath string, opts RunOpts) []string { + args := []string{sshPath} + if opts.User != "" { + args = append(args, "-l", opts.User) + } + if opts.Identity != "" { + args = append(args, "-i", opts.Identity) + } + args = append(args, opts.Hostname) + args = append(args, opts.PassThrough...) + return args +} + +func runWithPTY(cmd *exec.Cmd) (int, error) { + // Put local terminal in raw mode so Ctrl+C and Tab are sent as bytes to the + // remote instead of triggering SIGINT or local completion. + stdinFd := int(os.Stdin.Fd()) + oldState, err := term.MakeRaw(stdinFd) + if err != nil { + return 1, err + } + defer func() { _ = term.Restore(stdinFd, oldState) }() + + ptmx, err := pty.Start(cmd) + if err != nil { + return 1, err + } + defer ptmx.Close() + + // Initial terminal size from our stdin + if err := pty.InheritSize(os.Stdin, ptmx); err != nil { + // Non-fatal: continue without initial size + } + + // Resize PTY on SIGWINCH + winchCh := make(chan os.Signal, 1) + signal.Notify(winchCh, syscall.SIGWINCH) + go func() { + for range winchCh { + _ = pty.InheritSize(os.Stdin, ptmx) + } + }() + defer signal.Stop(winchCh) + // Trigger initial resize (in case InheritSize failed above) + winchCh <- syscall.SIGWINCH + + // Forward only SIGTERM to the child (e.g. from kill). Ctrl+C is sent as a + // byte in raw mode and goes through the PTY to the remote. + forwardCh := make(chan os.Signal, 1) + signal.Notify(forwardCh, syscall.SIGTERM) + go func() { + for sig := range forwardCh { + if cmd.Process != nil { + _ = cmd.Process.Signal(sig) + } + } + }() + defer signal.Stop(forwardCh) + + // Copy stdin -> pty and pty -> stdout + go func() { _, _ = io.Copy(ptmx, os.Stdin) }() + _, _ = io.Copy(os.Stdout, ptmx) + + if err := cmd.Wait(); err != nil { + return exitCodeFromError(err), nil + } + return 0, nil +} + +func runWithoutPTY(cmd *exec.Cmd) (int, error) { + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return exitCodeFromError(err), nil + } + return 0, nil +} diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go new file mode 100644 index 0000000..58665b9 --- /dev/null +++ b/cmd/ssh/ssh.go @@ -0,0 +1,53 @@ +package ssh + +import ( + "errors" + "os" + + "github.com/fosrl/cli/internal/logger" + "github.com/spf13/cobra" +) + +var errHostnameRequired = errors.New("--hostname is required") + +func SSHCmd() *cobra.Command { + opts := struct { + User string + Hostname string + Identity string + }{} + + cmd := &cobra.Command{ + Use: "ssh", + Short: "Run an interactive SSH session", + Long: `Run an SSH client in the terminal. Uses the system ssh binary with a PTY for interactive sessions.`, + PreRunE: func(cmd *cobra.Command, args []string) error { + if opts.Hostname == "" { + return errHostnameRequired + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + exitCode, err := Run(RunOpts{ + User: opts.User, + Hostname: opts.Hostname, + Identity: opts.Identity, + PassThrough: args, + }) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + os.Exit(exitCode) + }, + } + + cmd.Flags().StringVarP(&opts.User, "user", "u", "", "SSH login user (maps to ssh -l)") + cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Target host (required)") + cmd.Flags().StringVarP(&opts.Identity, "identity", "i", "", "Path to private key file") + + // Allow arbitrary args after flags (e.g. after --) to pass through to ssh + cmd.Args = cobra.ArbitraryArgs + + return cmd +} diff --git a/go.mod b/go.mod index 20b6f20..ad0509e 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,15 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 + github.com/creack/pty v1.1.24 github.com/fosrl/newt v1.9.0 github.com/fosrl/olm v1.4.1 + github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 golang.org/x/sys v0.40.0 + golang.org/x/term v0.38.0 ) require ( @@ -36,7 +39,6 @@ require ( github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/miekg/dns v1.1.70 // indirect diff --git a/go.sum b/go.sum index 3e8a633..0a635ad 100644 --- a/go.sum +++ b/go.sum @@ -149,6 +149,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/internal/version/consts.go b/internal/version/consts.go index 911bc89..29f7d4d 100644 --- a/internal/version/consts.go +++ b/internal/version/consts.go @@ -1,4 +1,4 @@ package version // Version is the current version of the Pangolin CLI -const Version = "0.3.2" +const Version = "0.3.3" From 72a7110a627e74344d1ad2200a14ed3a27d77365 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 14:01:54 -0800 Subject: [PATCH 02/21] use native go-based ssh client --- cmd/ssh/{runner.go => runner_exec.go} | 35 +++---- cmd/ssh/runner_native.go | 137 ++++++++++++++++++++++++++ cmd/ssh/ssh.go | 25 ++++- go.mod | 2 +- 4 files changed, 176 insertions(+), 23 deletions(-) rename cmd/ssh/{runner.go => runner_exec.go} (76%) create mode 100644 cmd/ssh/runner_native.go diff --git a/cmd/ssh/runner.go b/cmd/ssh/runner_exec.go similarity index 76% rename from cmd/ssh/runner.go rename to cmd/ssh/runner_exec.go index b18898a..cb68594 100644 --- a/cmd/ssh/runner.go +++ b/cmd/ssh/runner_exec.go @@ -14,18 +14,18 @@ import ( "golang.org/x/term" ) -// sshSearchPaths are fallback locations for the ssh executable when not in PATH. -var sshSearchPaths = []string{ +// execSSHSearchPaths are fallback locations for the ssh executable when not in PATH. +var execSSHSearchPaths = []string{ "/usr/bin/ssh", "/usr/local/bin/ssh", `C:\Windows\System32\OpenSSH\ssh.exe`, } -func findSSHPath() (string, error) { +func findExecSSHPath() (string, error) { if path, err := exec.LookPath("ssh"); err == nil { return path, nil } - for _, p := range sshSearchPaths { + for _, p := range execSSHSearchPaths { if isExecutable(p) { return p, nil } @@ -44,7 +44,7 @@ func isExecutable(path string) bool { return info.Mode()&0o111 != 0 } -func exitCodeFromError(err error) int { +func execExitCode(err error) int { if err == nil { return 0 } @@ -54,6 +54,7 @@ func exitCodeFromError(err error) int { return 1 } +// RunOpts is shared by both the exec and native SSH runners. type RunOpts struct { User string Hostname string @@ -61,26 +62,26 @@ type RunOpts struct { PassThrough []string } -// Run builds the ssh command from opts, runs it (with a PTY when stdin is a terminal on Unix), -// and returns the exit code and any error from starting/waiting. -func Run(opts RunOpts) (int, error) { - sshPath, err := findSSHPath() +// RunExec runs an interactive SSH session by executing the system ssh binary +// (with a PTY when stdin is a terminal on Unix). Requires ssh to be installed. +func RunExec(opts RunOpts) (int, error) { + sshPath, err := findExecSSHPath() if err != nil { return 1, err } - argv := buildSSHArgs(sshPath, opts) + argv := buildExecSSHArgs(sshPath, opts) cmd := exec.Command(argv[0], argv[1:]...) usePTY := runtime.GOOS != "windows" && isatty.IsTerminal(os.Stdin.Fd()) if usePTY { - return runWithPTY(cmd) + return runExecWithPTY(cmd) } - return runWithoutPTY(cmd) + return runExecWithoutPTY(cmd) } -func buildSSHArgs(sshPath string, opts RunOpts) []string { +func buildExecSSHArgs(sshPath string, opts RunOpts) []string { args := []string{sshPath} if opts.User != "" { args = append(args, "-l", opts.User) @@ -93,7 +94,7 @@ func buildSSHArgs(sshPath string, opts RunOpts) []string { return args } -func runWithPTY(cmd *exec.Cmd) (int, error) { +func runExecWithPTY(cmd *exec.Cmd) (int, error) { // Put local terminal in raw mode so Ctrl+C and Tab are sent as bytes to the // remote instead of triggering SIGINT or local completion. stdinFd := int(os.Stdin.Fd()) @@ -144,17 +145,17 @@ func runWithPTY(cmd *exec.Cmd) (int, error) { _, _ = io.Copy(os.Stdout, ptmx) if err := cmd.Wait(); err != nil { - return exitCodeFromError(err), nil + return execExitCode(err), nil } return 0, nil } -func runWithoutPTY(cmd *exec.Cmd) (int, error) { +func runExecWithoutPTY(cmd *exec.Cmd) (int, error) { cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { - return exitCodeFromError(err), nil + return execExitCode(err), nil } return 0, nil } diff --git a/cmd/ssh/runner_native.go b/cmd/ssh/runner_native.go new file mode 100644 index 0000000..ddd0a4d --- /dev/null +++ b/cmd/ssh/runner_native.go @@ -0,0 +1,137 @@ +package ssh + +import ( + "errors" + "fmt" + "net" + "os" + "os/signal" + "runtime" + "syscall" + + "github.com/mattn/go-isatty" + "golang.org/x/crypto/ssh" + "golang.org/x/term" +) + +const nativeDefaultSSHPort = "22" + +// RunNative runs an interactive SSH session using the pure-Go client (golang.org/x/crypto/ssh). +// It does not use the system ssh binary. Requires opts.Identity to be set (validated by caller). +func RunNative(opts RunOpts) (int, error) { + addr, err := nativeSSHAddress(opts.Hostname) + if err != nil { + return 1, err + } + + config, err := nativeSSHClientConfig(opts) + if err != nil { + return 1, err + } + + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return 1, fmt.Errorf("ssh dial: %w", err) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return 1, fmt.Errorf("ssh session: %w", err) + } + defer session.Close() + + stdinFd := int(os.Stdin.Fd()) + useRaw := isatty.IsTerminal(uintptr(stdinFd)) && runtime.GOOS != "windows" + if useRaw { + oldState, err := term.MakeRaw(stdinFd) + if err != nil { + return 1, err + } + defer func() { _ = term.Restore(stdinFd, oldState) }() + } + + width, height := 80, 24 + if useRaw { + if w, h, err := term.GetSize(stdinFd); err == nil { + width, height = w, h + } + } + + modes := ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + if err := session.RequestPty("xterm-256color", height, width, modes); err != nil { + return 1, fmt.Errorf("request pty: %w", err) + } + + // Resize on SIGWINCH (Unix, TTY only) + if useRaw { + winchCh := make(chan os.Signal, 1) + signal.Notify(winchCh, syscall.SIGWINCH) + go func() { + for range winchCh { + if w, h, err := term.GetSize(stdinFd); err == nil { + _ = session.WindowChange(h, w) + } + } + }() + defer signal.Stop(winchCh) + winchCh <- syscall.SIGWINCH + } + + session.Stdin = os.Stdin + session.Stdout = os.Stdout + session.Stderr = os.Stderr + + if err := session.Shell(); err != nil { + return 1, fmt.Errorf("shell: %w", err) + } + + if err := session.Wait(); err != nil { + // Session ended with an error (e.g. exit 1 on remote). No numeric code in protocol. + return 1, nil + } + return 0, nil +} + +func nativeSSHAddress(hostname string) (string, error) { + if hostname == "" { + return "", errors.New("hostname is empty") + } + if _, _, err := net.SplitHostPort(hostname); err == nil { + return hostname, nil + } + return net.JoinHostPort(hostname, nativeDefaultSSHPort), nil +} + +func nativeSSHClientConfig(opts RunOpts) (*ssh.ClientConfig, error) { + key, err := os.ReadFile(opts.Identity) + if err != nil { + return nil, fmt.Errorf("read identity file: %w", err) + } + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + + user := opts.User + if user == "" { + user = os.Getenv("USER") + if user == "" { + user = os.Getenv("USERNAME") + } + if user == "" { + user = "root" + } + } + + return &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + // Host key verification disabled for simplicity; can be enhanced with known_hosts later. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }, nil +} diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 58665b9..a47e1de 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -8,32 +8,46 @@ import ( "github.com/spf13/cobra" ) -var errHostnameRequired = errors.New("--hostname is required") +var ( + errHostnameRequired = errors.New("--hostname is required") + errIdentityRequired = errors.New("identity file (-i) is required for the built-in SSH client") +) func SSHCmd() *cobra.Command { opts := struct { User string Hostname string Identity string + Exec bool }{} cmd := &cobra.Command{ Use: "ssh", Short: "Run an interactive SSH session", - Long: `Run an SSH client in the terminal. Uses the system ssh binary with a PTY for interactive sessions.`, + Long: `Run an SSH client in the terminal. By default uses the built-in Go SSH client (no system ssh required). Use --exec to run the system ssh binary instead.`, PreRunE: func(cmd *cobra.Command, args []string) error { if opts.Hostname == "" { return errHostnameRequired } + if !opts.Exec && opts.Identity == "" { + return errIdentityRequired + } return nil }, Run: func(cmd *cobra.Command, args []string) { - exitCode, err := Run(RunOpts{ + runOpts := RunOpts{ User: opts.User, Hostname: opts.Hostname, Identity: opts.Identity, PassThrough: args, - }) + } + var exitCode int + var err error + if opts.Exec { + exitCode, err = RunExec(runOpts) + } else { + exitCode, err = RunNative(runOpts) + } if err != nil { logger.Error("%v", err) os.Exit(1) @@ -44,7 +58,8 @@ func SSHCmd() *cobra.Command { cmd.Flags().StringVarP(&opts.User, "user", "u", "", "SSH login user (maps to ssh -l)") cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Target host (required)") - cmd.Flags().StringVarP(&opts.Identity, "identity", "i", "", "Path to private key file") + cmd.Flags().StringVarP(&opts.Identity, "identity", "i", "", "Path to private key file (required for built-in client)") + cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") // Allow arbitrary args after flags (e.g. after --) to pass through to ssh cmd.Args = cobra.ArbitraryArgs diff --git a/go.mod b/go.mod index ad0509e..f520217 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.46.0 golang.org/x/sys v0.40.0 golang.org/x/term v0.38.0 ) @@ -59,7 +60,6 @@ require ( github.com/vishvananda/netns 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/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect From 5a496397bc778c43266b757f5e719e0faa43545b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 14:41:34 -0800 Subject: [PATCH 03/21] add private key and certificate support --- cmd/ssh/runner_exec.go | 15 ++++++++++--- cmd/ssh/runner_native.go | 47 ++++++++++++++++++++++++++++++++++++---- cmd/ssh/ssh.go | 22 ++++++++++++------- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/cmd/ssh/runner_exec.go b/cmd/ssh/runner_exec.go index cb68594..cb24fea 100644 --- a/cmd/ssh/runner_exec.go +++ b/cmd/ssh/runner_exec.go @@ -58,7 +58,9 @@ func execExitCode(err error) int { type RunOpts struct { User string Hostname string - Identity string + Identity string // path to identity/private key (alias for private key) + PrivateKey string // path to private key file + Certificate string // path to certificate file (optional) PassThrough []string } @@ -86,8 +88,15 @@ func buildExecSSHArgs(sshPath string, opts RunOpts) []string { if opts.User != "" { args = append(args, "-l", opts.User) } - if opts.Identity != "" { - args = append(args, "-i", opts.Identity) + keyPath := opts.PrivateKey + if keyPath == "" { + keyPath = opts.Identity + } + if keyPath != "" { + args = append(args, "-i", keyPath) + } + if opts.Certificate != "" { + args = append(args, "-o", "CertificateFile="+opts.Certificate) } args = append(args, opts.Hostname) args = append(args, opts.PassThrough...) diff --git a/cmd/ssh/runner_native.go b/cmd/ssh/runner_native.go index ddd0a4d..a0a0bde 100644 --- a/cmd/ssh/runner_native.go +++ b/cmd/ssh/runner_native.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "runtime" + "strings" "syscall" "github.com/mattn/go-isatty" @@ -17,7 +18,7 @@ import ( const nativeDefaultSSHPort = "22" // RunNative runs an interactive SSH session using the pure-Go client (golang.org/x/crypto/ssh). -// It does not use the system ssh binary. Requires opts.Identity to be set (validated by caller). +// It does not use the system ssh binary. Requires a private key (opts.Identity or opts.PrivateKey); opts.Certificate is optional. func RunNative(opts RunOpts) (int, error) { addr, err := nativeSSHAddress(opts.Hostname) if err != nil { @@ -97,6 +98,12 @@ func RunNative(opts RunOpts) (int, error) { return 0, nil } +func looksLikeCertificate(data []byte) bool { + s := string(data) + return strings.Contains(s, "-cert-v01@openssh.com") || strings.Contains(s, "-cert@openssh.com") || + strings.Contains(s, "ssh-rsa-cert") || strings.Contains(s, "ssh-ed25519-cert") || strings.Contains(s, "ecdsa-sha2-nistp256-cert") +} + func nativeSSHAddress(hostname string) (string, error) { if hostname == "" { return "", errors.New("hostname is empty") @@ -108,15 +115,47 @@ func nativeSSHAddress(hostname string) (string, error) { } func nativeSSHClientConfig(opts RunOpts) (*ssh.ClientConfig, error) { - key, err := os.ReadFile(opts.Identity) + keyPath := opts.PrivateKey + if keyPath == "" { + keyPath = opts.Identity + } + if keyPath == "" { + return nil, errors.New("private key path required (set -i or --private-key)") + } + + key, err := os.ReadFile(keyPath) if err != nil { - return nil, fmt.Errorf("read identity file: %w", err) + return nil, fmt.Errorf("read private key: %w", err) } signer, err := ssh.ParsePrivateKey(key) if err != nil { + if looksLikeCertificate(key) { + return nil, fmt.Errorf("parse private key: %w (hint: %s looks like a certificate file; use --certificate for the cert and -i or --private-key for the private key file)", err, keyPath) + } return nil, fmt.Errorf("parse private key: %w", err) } + authSigner := signer + if opts.Certificate != "" { + certBytes, err := os.ReadFile(opts.Certificate) + if err != nil { + return nil, fmt.Errorf("read certificate: %w", err) + } + // ParseAuthorizedKey handles OpenSSH cert format (single/multi-line, wrapped base64). + pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes) + if err != nil { + return nil, fmt.Errorf("parse certificate: %w", err) + } + cert, ok := pubKey.(*ssh.Certificate) + if !ok { + return nil, fmt.Errorf("certificate file is not an SSH certificate") + } + authSigner, err = ssh.NewCertSigner(cert, signer) + if err != nil { + return nil, fmt.Errorf("create cert signer: %w", err) + } + } + user := opts.User if user == "" { user = os.Getenv("USER") @@ -130,7 +169,7 @@ func nativeSSHClientConfig(opts RunOpts) (*ssh.ClientConfig, error) { return &ssh.ClientConfig{ User: user, - Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + Auth: []ssh.AuthMethod{ssh.PublicKeys(authSigner)}, // Host key verification disabled for simplicity; can be enhanced with known_hosts later. HostKeyCallback: ssh.InsecureIgnoreHostKey(), }, nil diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index a47e1de..fb0b317 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -10,15 +10,17 @@ import ( var ( errHostnameRequired = errors.New("--hostname is required") - errIdentityRequired = errors.New("identity file (-i) is required for the built-in SSH client") + errKeyRequired = errors.New("private key required for built-in client (set -i or --private-key)") ) func SSHCmd() *cobra.Command { opts := struct { - User string - Hostname string - Identity string - Exec bool + User string + Hostname string + Identity string + PrivateKey string + Certificate string + Exec bool }{} cmd := &cobra.Command{ @@ -29,8 +31,8 @@ func SSHCmd() *cobra.Command { if opts.Hostname == "" { return errHostnameRequired } - if !opts.Exec && opts.Identity == "" { - return errIdentityRequired + if !opts.Exec && opts.Identity == "" && opts.PrivateKey == "" { + return errKeyRequired } return nil }, @@ -39,6 +41,8 @@ func SSHCmd() *cobra.Command { User: opts.User, Hostname: opts.Hostname, Identity: opts.Identity, + PrivateKey: opts.PrivateKey, + Certificate: opts.Certificate, PassThrough: args, } var exitCode int @@ -58,7 +62,9 @@ func SSHCmd() *cobra.Command { cmd.Flags().StringVarP(&opts.User, "user", "u", "", "SSH login user (maps to ssh -l)") cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Target host (required)") - cmd.Flags().StringVarP(&opts.Identity, "identity", "i", "", "Path to private key file (required for built-in client)") + cmd.Flags().StringVarP(&opts.Identity, "identity", "i", "", "Path to identity or private key file") + cmd.Flags().StringVar(&opts.PrivateKey, "private-key", "", "Path to private key file") + cmd.Flags().StringVar(&opts.Certificate, "certificate", "", "Path to certificate file (optional, for certificate auth)") cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") // Allow arbitrary args after flags (e.g. after --) to pass through to ssh From 305c03601ae8cfa792b04254033cf4eef088b7d5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 14:47:24 -0800 Subject: [PATCH 04/21] add priv/pub cert generation and signing endpoint --- internal/api/client.go | 13 +++++ internal/api/types.go | 19 ++++++++ internal/sshkeys/keys.go | 100 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 internal/sshkeys/keys.go diff --git a/internal/api/client.go b/internal/api/client.go index 3e0be5f..a0c82b0 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -360,6 +360,19 @@ func (c *Client) CheckOrgUserAccess(orgID, userID string) (*CheckOrgUserAccessRe return &response, nil } +func (c *Client) SignSSHKey(orgID string, req SignSSHKeyRequest) (*SignSSHKeyData, error) { + path := fmt.Sprintf("/org/%s/ssh/sign-key", orgID) + var response SignSSHKeyResponse + err := c.Post(path, req, &response) + if err != nil { + return nil, err + } + if !response.Success { + return nil, fmt.Errorf("ssh sign-key failed") + } + return &response.Data, nil +} + // GetClient gets a client by ID func (c *Client) GetClient(clientID int) (*GetClientResponse, error) { path := fmt.Sprintf("/client/%d", clientID) diff --git a/internal/api/types.go b/internal/api/types.go index 46caf75..5f33927 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -273,3 +273,22 @@ type ApplyBlueprintResponse struct { Succeeded bool `json:"succeeded"` Contents string `json:"contents"` } + +type SignSSHKeyRequest struct { + PublicKey string `json:"publicKey"` + ResourceID int `json:"resourceId"` +} + +type SignSSHKeyData struct { + Certificate string `json:"certificate"` + KeyID string `json:"keyId"` + ValidPrincipals []string `json:"validPrincipals"` + ValidAfter string `json:"validAfter"` + ValidBefore string `json:"validBefore"` + ExpiresInSeconds int `json:"expiresIn"` +} + +type SignSSHKeyResponse struct { + Success bool `json:"success"` + Data SignSSHKeyData `json:"data"` +} diff --git a/internal/sshkeys/keys.go b/internal/sshkeys/keys.go new file mode 100644 index 0000000..5340281 --- /dev/null +++ b/internal/sshkeys/keys.go @@ -0,0 +1,100 @@ +package sshkeys + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "fmt" + + "golang.org/x/crypto/ssh" +) + +// GenerateKeyPair generates an Ed25519 SSH key pair in memory and returns the +// private and public keys as strings. Nothing is written to disk. +// +// privateKey: PEM-encoded private key (OpenSSH format). +// publicKey: Authorized-keys style single line (e.g. "ssh-ed25519 AAAA..."). +func GenerateKeyPair() (privateKey string, publicKey string, err error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", "", fmt.Errorf("generate key: %w", err) + } + + // OpenSSH format (-----BEGIN OPENSSH PRIVATE KEY-----) + block, err := marshalOpenSSHPrivateKey(priv) + if err != nil { + return "", "", fmt.Errorf("marshal private key: %w", err) + } + privPEM := string(pem.EncodeToMemory(block)) + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return "", "", fmt.Errorf("marshal public key: %w", err) + } + pubLine := string(ssh.MarshalAuthorizedKey(sshPub)) + pubLine = string(bytes.TrimSuffix([]byte(pubLine), []byte("\n"))) + publicKey = pubLine + + return privPEM, publicKey, nil +} + +// marshalOpenSSHPrivateKey returns a PEM block for the OpenSSH private key format +// (-----BEGIN OPENSSH PRIVATE KEY-----). Uses crypto/rand for check bytes. +func marshalOpenSSHPrivateKey(key ed25519.PrivateKey) (*pem.Block, error) { + magic := append([]byte("openssh-key-v1"), 0) + + var w struct { + CipherName string + KdfName string + KdfOpts string + NumKeys uint32 + PubKey []byte + PrivKeyBlock []byte + } + + ci := make([]byte, 4) + if _, err := rand.Read(ci); err != nil { + return nil, err + } + checkVal := uint32(ci[0])<<24 | uint32(ci[1])<<16 | uint32(ci[2])<<8 | uint32(ci[3]) + + pk1 := struct { + Check1 uint32 + Check2 uint32 + Keytype string + Pub []byte + Priv []byte + Comment string + Pad []byte `ssh:"rest"` + }{ + Check1: checkVal, + Check2: checkVal, + Keytype: ssh.KeyAlgoED25519, + Pub: []byte(key.Public().(ed25519.PublicKey)), + Priv: []byte(key), + Comment: "", + } + + blockLen := len(ssh.Marshal(pk1)) + padLen := (8 - (blockLen % 8)) % 8 + pk1.Pad = make([]byte, padLen) + for i := 0; i < padLen; i++ { + pk1.Pad[i] = byte(i + 1) + } + + prefix := []byte{0x0, 0x0, 0x0, 0x0b} + prefix = append(prefix, []byte(ssh.KeyAlgoED25519)...) + prefix = append(prefix, []byte{0x0, 0x0, 0x0, 0x20}...) + + w.CipherName = "none" + w.KdfName = "none" + w.KdfOpts = "" + w.NumKeys = 1 + w.PubKey = append(prefix, pk1.Pub...) + w.PrivKeyBlock = ssh.Marshal(pk1) + + magic = append(magic, ssh.Marshal(w)...) + + return &pem.Block{Type: "OPENSSH PRIVATE KEY", Bytes: magic}, nil +} From c5d1bc2dd983b6b7fbb48d0f640f1eb098f5931c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 14:55:01 -0800 Subject: [PATCH 05/21] add just in time key generation and signing --- cmd/ssh/runner_exec.go | 96 ++++++++++++++++++++++++++++++++-------- cmd/ssh/runner_native.go | 25 +++-------- cmd/ssh/ssh.go | 80 +++++++++++++++++++++++---------- 3 files changed, 141 insertions(+), 60 deletions(-) diff --git a/cmd/ssh/runner_exec.go b/cmd/ssh/runner_exec.go index cb24fea..de5ce8f 100644 --- a/cmd/ssh/runner_exec.go +++ b/cmd/ssh/runner_exec.go @@ -55,24 +55,33 @@ func execExitCode(err error) int { } // RunOpts is shared by both the exec and native SSH runners. +// PrivateKeyPEM and Certificate are set just-in-time (JIT) before connect; no file paths. type RunOpts struct { - User string - Hostname string - Identity string // path to identity/private key (alias for private key) - PrivateKey string // path to private key file - Certificate string // path to certificate file (optional) - PassThrough []string + User string + Hostname string + PrivateKeyPEM string // in-memory private key (PEM, OpenSSH format) + Certificate string // in-memory certificate from sign-key API + PassThrough []string } // RunExec runs an interactive SSH session by executing the system ssh binary // (with a PTY when stdin is a terminal on Unix). Requires ssh to be installed. +// opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert). func RunExec(opts RunOpts) (int, error) { sshPath, err := findExecSSHPath() if err != nil { return 1, err } - argv := buildExecSSHArgs(sshPath, opts) + keyPath, certPath, cleanup, err := writeExecKeyFiles(opts) + if err != nil { + return 1, err + } + if cleanup != nil { + defer cleanup() + } + + argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, keyPath, certPath, opts.PassThrough) cmd := exec.Command(argv[0], argv[1:]...) usePTY := runtime.GOOS != "windows" && isatty.IsTerminal(os.Stdin.Fd()) @@ -83,23 +92,74 @@ func RunExec(opts RunOpts) (int, error) { return runExecWithoutPTY(cmd) } -func buildExecSSHArgs(sshPath string, opts RunOpts) []string { - args := []string{sshPath} - if opts.User != "" { - args = append(args, "-l", opts.User) +// writeExecKeyFiles writes PrivateKeyPEM and Certificate to temp files for system ssh. +// Returns keyPath, certPath, cleanup func, error. +func writeExecKeyFiles(opts RunOpts) (keyPath, certPath string, cleanup func(), err error) { + if opts.PrivateKeyPEM == "" { + return "", "", nil, errors.New("private key required (JIT flow)") + } + keyFile, err := os.CreateTemp("", "pangolin-ssh-key-*") + if err != nil { + return "", "", nil, err + } + if _, err := keyFile.WriteString(opts.PrivateKeyPEM); err != nil { + keyFile.Close() + os.Remove(keyFile.Name()) + return "", "", nil, err + } + if err := keyFile.Chmod(0o600); err != nil { + keyFile.Close() + os.Remove(keyFile.Name()) + return "", "", nil, err + } + if err := keyFile.Close(); err != nil { + os.Remove(keyFile.Name()) + return "", "", nil, err + } + keyPath = keyFile.Name() + + if opts.Certificate != "" { + certFile, err := os.CreateTemp("", "pangolin-ssh-cert-*") + if err != nil { + os.Remove(keyPath) + return "", "", nil, err + } + if _, err := certFile.WriteString(opts.Certificate); err != nil { + certFile.Close() + os.Remove(certFile.Name()) + os.Remove(keyPath) + return "", "", nil, err + } + if err := certFile.Close(); err != nil { + os.Remove(certFile.Name()) + os.Remove(keyPath) + return "", "", nil, err + } + certPath = certFile.Name() + } + + cleanup = func() { + os.Remove(keyPath) + if certPath != "" { + os.Remove(certPath) + } } - keyPath := opts.PrivateKey - if keyPath == "" { - keyPath = opts.Identity + return keyPath, certPath, cleanup, nil +} + +func buildExecSSHArgs(sshPath, user, hostname, keyPath, certPath string, passThrough []string) []string { + args := []string{sshPath} + if user != "" { + args = append(args, "-l", user) } if keyPath != "" { args = append(args, "-i", keyPath) } - if opts.Certificate != "" { - args = append(args, "-o", "CertificateFile="+opts.Certificate) + if certPath != "" { + args = append(args, "-o", "CertificateFile="+certPath) } - args = append(args, opts.Hostname) - args = append(args, opts.PassThrough...) + args = append(args, hostname) + args = append(args, passThrough...) return args } diff --git a/cmd/ssh/runner_native.go b/cmd/ssh/runner_native.go index a0a0bde..2c637fd 100644 --- a/cmd/ssh/runner_native.go +++ b/cmd/ssh/runner_native.go @@ -18,7 +18,7 @@ import ( const nativeDefaultSSHPort = "22" // RunNative runs an interactive SSH session using the pure-Go client (golang.org/x/crypto/ssh). -// It does not use the system ssh binary. Requires a private key (opts.Identity or opts.PrivateKey); opts.Certificate is optional. +// It does not use the system ssh binary. opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert). func RunNative(opts RunOpts) (int, error) { addr, err := nativeSSHAddress(opts.Hostname) if err != nil { @@ -115,40 +115,29 @@ func nativeSSHAddress(hostname string) (string, error) { } func nativeSSHClientConfig(opts RunOpts) (*ssh.ClientConfig, error) { - keyPath := opts.PrivateKey - if keyPath == "" { - keyPath = opts.Identity - } - if keyPath == "" { - return nil, errors.New("private key path required (set -i or --private-key)") + if opts.PrivateKeyPEM == "" { + return nil, errors.New("private key required (JIT flow)") } - key, err := os.ReadFile(keyPath) - if err != nil { - return nil, fmt.Errorf("read private key: %w", err) - } + key := []byte(opts.PrivateKeyPEM) signer, err := ssh.ParsePrivateKey(key) if err != nil { if looksLikeCertificate(key) { - return nil, fmt.Errorf("parse private key: %w (hint: %s looks like a certificate file; use --certificate for the cert and -i or --private-key for the private key file)", err, keyPath) + return nil, fmt.Errorf("parse private key: %w (hint: key material looks like a certificate)", err) } return nil, fmt.Errorf("parse private key: %w", err) } authSigner := signer if opts.Certificate != "" { - certBytes, err := os.ReadFile(opts.Certificate) - if err != nil { - return nil, fmt.Errorf("read certificate: %w", err) - } - // ParseAuthorizedKey handles OpenSSH cert format (single/multi-line, wrapped base64). + certBytes := []byte(opts.Certificate) pubKey, _, _, _, err := ssh.ParseAuthorizedKey(certBytes) if err != nil { return nil, fmt.Errorf("parse certificate: %w", err) } cert, ok := pubKey.(*ssh.Certificate) if !ok { - return nil, fmt.Errorf("certificate file is not an SSH certificate") + return nil, fmt.Errorf("certificate is not an SSH certificate") } authSigner, err = ssh.NewCertSigner(cert, signer) if err != nil { diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index fb0b317..e0c5713 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -4,49 +4,83 @@ import ( "errors" "os" + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/sshkeys" "github.com/spf13/cobra" ) var ( - errHostnameRequired = errors.New("--hostname is required") - errKeyRequired = errors.New("private key required for built-in client (set -i or --private-key)") + errHostnameRequired = errors.New("--hostname is required") + errResourceIDRequired = errors.New("--resource-id is required to sign the SSH key") + errOrgRequired = errors.New("--org is required, or select an organization (pangolin select org)") ) func SSHCmd() *cobra.Command { opts := struct { - User string - Hostname string - Identity string - PrivateKey string - Certificate string - Exec bool + User string + Hostname string + OrgID string + ResourceID int + Exec bool }{} cmd := &cobra.Command{ Use: "ssh", Short: "Run an interactive SSH session", - Long: `Run an SSH client in the terminal. By default uses the built-in Go SSH client (no system ssh required). Use --exec to run the system ssh binary instead.`, - PreRunE: func(cmd *cobra.Command, args []string) error { + Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time via the API, then connects. By default uses the built-in Go SSH client; use --exec to run the system ssh binary instead.`, + PreRunE: func(c *cobra.Command, args []string) error { if opts.Hostname == "" { return errHostnameRequired } - if !opts.Exec && opts.Identity == "" && opts.PrivateKey == "" { - return errKeyRequired + if opts.ResourceID == 0 { + return errResourceIDRequired } return nil }, - Run: func(cmd *cobra.Command, args []string) { + Run: func(c *cobra.Command, args []string) { + apiClient := api.FromContext(c.Context()) + accountStore := config.AccountStoreFromContext(c.Context()) + + orgID := opts.OrgID + if orgID == "" { + active, err := accountStore.ActiveAccount() + if err != nil || active == nil { + logger.Error("%v", errOrgRequired) + os.Exit(1) + } + orgID = active.OrgID + if orgID == "" { + logger.Error("%v", errOrgRequired) + os.Exit(1) + } + } + + privPEM, pubKey, err := sshkeys.GenerateKeyPair() + if err != nil { + logger.Error("generate key pair: %v", err) + os.Exit(1) + } + + signData, err := apiClient.SignSSHKey(orgID, api.SignSSHKeyRequest{ + PublicKey: pubKey, + ResourceID: opts.ResourceID, + }) + if err != nil { + logger.Error("sign SSH key: %v", err) + os.Exit(1) + } + runOpts := RunOpts{ - User: opts.User, - Hostname: opts.Hostname, - Identity: opts.Identity, - PrivateKey: opts.PrivateKey, - Certificate: opts.Certificate, - PassThrough: args, + User: opts.User, + Hostname: opts.Hostname, + PrivateKeyPEM: privPEM, + Certificate: signData.Certificate, + PassThrough: args, } + var exitCode int - var err error if opts.Exec { exitCode, err = RunExec(runOpts) } else { @@ -62,12 +96,10 @@ func SSHCmd() *cobra.Command { cmd.Flags().StringVarP(&opts.User, "user", "u", "", "SSH login user (maps to ssh -l)") cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Target host (required)") - cmd.Flags().StringVarP(&opts.Identity, "identity", "i", "", "Path to identity or private key file") - cmd.Flags().StringVar(&opts.PrivateKey, "private-key", "", "Path to private key file") - cmd.Flags().StringVar(&opts.Certificate, "certificate", "", "Path to certificate file (optional, for certificate auth)") + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization)") + cmd.Flags().IntVar(&opts.ResourceID, "resource-id", 0, "Resource ID for key signing (required)") cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") - // Allow arbitrary args after flags (e.g. after --) to pass through to ssh cmd.Args = cobra.ArbitraryArgs return cmd From b0e3151a49d7d4cdfbb7f0504341fc6d3658dc28 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 15:12:26 -0800 Subject: [PATCH 06/21] support sign and output keys jit --- cmd/ssh/jit.go | 43 +++++++++++++ cmd/ssh/sign.go | 165 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/ssh/ssh.go | 32 +++------- 3 files changed, 216 insertions(+), 24 deletions(-) create mode 100644 cmd/ssh/jit.go create mode 100644 cmd/ssh/sign.go diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go new file mode 100644 index 0000000..1d39bb9 --- /dev/null +++ b/cmd/ssh/jit.go @@ -0,0 +1,43 @@ +package ssh + +import ( + "fmt" + + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/sshkeys" +) + +// GenerateAndSignKey generates an Ed25519 key pair and signs the public key via the API. +// Returns private key (PEM), public key (authorized_keys line), certificate, and sign response data. No files are written. +func GenerateAndSignKey(client *api.Client, orgID string, resourceID int) (privPEM, pubKey, cert string, signData *api.SignSSHKeyData, err error) { + privPEM, pubKey, err = sshkeys.GenerateKeyPair() + if err != nil { + return "", "", "", nil, fmt.Errorf("generate key pair: %w", err) + } + + data, err := client.SignSSHKey(orgID, api.SignSSHKeyRequest{ + PublicKey: pubKey, + ResourceID: resourceID, + }) + if err != nil { + return "", "", "", nil, fmt.Errorf("sign SSH key: %w", err) + } + + return privPEM, pubKey, data.Certificate, data, nil +} + +// ResolveOrgID returns orgID from the flag or the active account. Returns empty string and nil error if both are empty. +func ResolveOrgID(accountStore *config.AccountStore, flagOrgID string) (string, error) { + if flagOrgID != "" { + return flagOrgID, nil + } + active, err := accountStore.ActiveAccount() + if err != nil || active == nil { + return "", errOrgRequired + } + if active.OrgID == "" { + return "", errOrgRequired + } + return active.OrgID, nil +} diff --git a/cmd/ssh/sign.go b/cmd/ssh/sign.go new file mode 100644 index 0000000..2e04759 --- /dev/null +++ b/cmd/ssh/sign.go @@ -0,0 +1,165 @@ +package ssh + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fosrl/cli/internal/api" + "github.com/fosrl/cli/internal/config" + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/utils" + "github.com/spf13/cobra" +) + +var errKeyFileRequired = errors.New("--key-file is required") + +func SignCmd() *cobra.Command { + opts := struct { + OrgID string + ResourceID int + KeyFile string + CertFile string + Hostname string + }{} + + cmd := &cobra.Command{ + Use: "sign", + Short: "Generate and sign an SSH key, then save to files for use with system SSH.", + Long: `Generates a key pair, signs the public key, and writes the private key and certificate to files.`, + PreRunE: func(c *cobra.Command, args []string) error { + if opts.KeyFile == "" { + return errKeyFileRequired + } + if opts.ResourceID == 0 { + return errResourceIDRequired + } + return nil + }, + Run: func(c *cobra.Command, args []string) { + apiClient := api.FromContext(c.Context()) + accountStore := config.AccountStoreFromContext(c.Context()) + + orgID, err := ResolveOrgID(accountStore, opts.OrgID) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + + privPEM, _, cert, signData, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + + keyPath, err := filepath.Abs(opts.KeyFile) + if err != nil { + keyPath = opts.KeyFile + } + certPath := opts.CertFile + if certPath == "" { + certPath = keyPath + "-cert.pub" + } else { + certPath, err = filepath.Abs(certPath) + if err != nil { + certPath = opts.CertFile + } + } + + if err := os.WriteFile(keyPath, []byte(privPEM), 0o600); err != nil { + logger.Error("write key file: %v", err) + os.Exit(1) + } + if err := os.WriteFile(certPath, []byte(cert), 0o644); err != nil { + os.Remove(keyPath) + logger.Error("write certificate file: %v", err) + os.Exit(1) + } + + logger.Success("Private key: %s", keyPath) + logger.Success("Certificate: %s", certPath) + fmt.Println() + + // Certificate details table + utils.PrintTable([]string{"Field", "Value"}, signCertTableRows(signData)) + fmt.Println() + + hostname := opts.Hostname + if hostname == "" { + hostname = "" + } + fmt.Println("Usage with system ssh (scp, tunnels, etc.):") + fmt.Printf(" ssh -i %q -o CertificateFile=%q %s\n", keyPath, certPath, hostname) + fmt.Printf(" scp -i %q -o CertificateFile=%q ...\n", keyPath, certPath) + }, + } + + cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization)") + cmd.Flags().IntVar(&opts.ResourceID, "resource-id", 0, "Resource ID for key signing (required)") + cmd.Flags().StringVar(&opts.KeyFile, "key-file", "", "Path to write the private key (required)") + cmd.Flags().StringVar(&opts.CertFile, "cert-file", "", "Path to write the certificate (default: -cert.pub)") + cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Hostname for the prefilled ssh example") + + return cmd +} + +// signCertTableRows builds table rows for certificate metadata (Key ID, principals, valid after/before, expires in). +func signCertTableRows(d *api.SignSSHKeyData) [][]string { + if d == nil { + return nil + } + principals := strings.Join(d.ValidPrincipals, ", ") + if principals == "" { + principals = "-" + } + return [][]string{ + {"Key ID", d.KeyID}, + {"Principals", principals}, + {"Valid after", formatSignDate(d.ValidAfter)}, + {"Valid before", formatSignDate(d.ValidBefore)}, + {"Expires in", formatExpiresIn(d.ExpiresInSeconds)}, + } +} + +func formatSignDate(iso string) string { + if iso == "" { + return "-" + } + t, err := time.Parse(time.RFC3339, iso) + if err != nil { + return iso + } + return t.Format("Jan 2, 2006 15:04 MST") +} + +func formatExpiresIn(seconds int) string { + if seconds <= 0 { + return "-" + } + d := time.Duration(seconds) * time.Second + if d >= 24*time.Hour { + days := int(d.Hours() / 24) + if days == 1 { + return "1 day" + } + return fmt.Sprintf("%d days", days) + } + if d >= time.Hour { + h := int(d.Hours()) + if h == 1 { + return "1 hour" + } + return fmt.Sprintf("%d hours", h) + } + if d >= time.Minute { + m := int(d.Minutes()) + if m == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", m) + } + return fmt.Sprintf("%d seconds", int(d.Seconds())) +} diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index e0c5713..0644e5c 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -7,7 +7,6 @@ import ( "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/logger" - "github.com/fosrl/cli/internal/sshkeys" "github.com/spf13/cobra" ) @@ -29,7 +28,7 @@ func SSHCmd() *cobra.Command { cmd := &cobra.Command{ Use: "ssh", Short: "Run an interactive SSH session", - Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time via the API, then connects. By default uses the built-in Go SSH client; use --exec to run the system ssh binary instead.`, + Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time, then connects to the target resource.`, PreRunE: func(c *cobra.Command, args []string) error { if opts.Hostname == "" { return errHostnameRequired @@ -43,32 +42,15 @@ func SSHCmd() *cobra.Command { apiClient := api.FromContext(c.Context()) accountStore := config.AccountStoreFromContext(c.Context()) - orgID := opts.OrgID - if orgID == "" { - active, err := accountStore.ActiveAccount() - if err != nil || active == nil { - logger.Error("%v", errOrgRequired) - os.Exit(1) - } - orgID = active.OrgID - if orgID == "" { - logger.Error("%v", errOrgRequired) - os.Exit(1) - } - } - - privPEM, pubKey, err := sshkeys.GenerateKeyPair() + orgID, err := ResolveOrgID(accountStore, opts.OrgID) if err != nil { - logger.Error("generate key pair: %v", err) + logger.Error("%v", err) os.Exit(1) } - signData, err := apiClient.SignSSHKey(orgID, api.SignSSHKeyRequest{ - PublicKey: pubKey, - ResourceID: opts.ResourceID, - }) + privPEM, _, cert, _, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID) if err != nil { - logger.Error("sign SSH key: %v", err) + logger.Error("%v", err) os.Exit(1) } @@ -76,7 +58,7 @@ func SSHCmd() *cobra.Command { User: opts.User, Hostname: opts.Hostname, PrivateKeyPEM: privPEM, - Certificate: signData.Certificate, + Certificate: cert, PassThrough: args, } @@ -102,5 +84,7 @@ func SSHCmd() *cobra.Command { cmd.Args = cobra.ArbitraryArgs + cmd.AddCommand(SignCmd()) + return cmd } From 6ad3d86b02423604d1a70eee19db9c4430314770 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 15:17:00 -0800 Subject: [PATCH 07/21] get user and hostname from api --- cmd/ssh/sign.go | 10 ++++++---- cmd/ssh/ssh.go | 22 ++++++++++------------ internal/api/types.go | 2 ++ 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/cmd/ssh/sign.go b/cmd/ssh/sign.go index 2e04759..53a9219 100644 --- a/cmd/ssh/sign.go +++ b/cmd/ssh/sign.go @@ -23,7 +23,6 @@ func SignCmd() *cobra.Command { ResourceID int KeyFile string CertFile string - Hostname string }{} cmd := &cobra.Command{ @@ -87,12 +86,16 @@ func SignCmd() *cobra.Command { utils.PrintTable([]string{"Field", "Value"}, signCertTableRows(signData)) fmt.Println() - hostname := opts.Hostname + hostname := signData.Hostname if hostname == "" { hostname = "" } + user := signData.User + if user == "" { + user = "" + } fmt.Println("Usage with system ssh (scp, tunnels, etc.):") - fmt.Printf(" ssh -i %q -o CertificateFile=%q %s\n", keyPath, certPath, hostname) + fmt.Printf(" ssh -i %q -o CertificateFile=%q %s@%s\n", keyPath, certPath, user, hostname) fmt.Printf(" scp -i %q -o CertificateFile=%q ...\n", keyPath, certPath) }, } @@ -101,7 +104,6 @@ func SignCmd() *cobra.Command { cmd.Flags().IntVar(&opts.ResourceID, "resource-id", 0, "Resource ID for key signing (required)") cmd.Flags().StringVar(&opts.KeyFile, "key-file", "", "Path to write the private key (required)") cmd.Flags().StringVar(&opts.CertFile, "cert-file", "", "Path to write the certificate (default: -cert.pub)") - cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Hostname for the prefilled ssh example") return cmd } diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 0644e5c..7d21011 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -11,15 +11,13 @@ import ( ) var ( - errHostnameRequired = errors.New("--hostname is required") + errHostnameRequired = errors.New("API did not return a hostname for the connection") errResourceIDRequired = errors.New("--resource-id is required to sign the SSH key") errOrgRequired = errors.New("--org is required, or select an organization (pangolin select org)") ) func SSHCmd() *cobra.Command { opts := struct { - User string - Hostname string OrgID string ResourceID int Exec bool @@ -30,9 +28,6 @@ func SSHCmd() *cobra.Command { Short: "Run an interactive SSH session", Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time, then connects to the target resource.`, PreRunE: func(c *cobra.Command, args []string) error { - if opts.Hostname == "" { - return errHostnameRequired - } if opts.ResourceID == 0 { return errResourceIDRequired } @@ -48,15 +43,19 @@ func SSHCmd() *cobra.Command { os.Exit(1) } - privPEM, _, cert, _, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID) + privPEM, _, cert, signData, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID) if err != nil { logger.Error("%v", err) os.Exit(1) } + if signData == nil || signData.Hostname == "" { + logger.Error("%v", errHostnameRequired) + os.Exit(1) + } runOpts := RunOpts{ - User: opts.User, - Hostname: opts.Hostname, + User: signData.User, + Hostname: signData.Hostname, PrivateKeyPEM: privPEM, Certificate: cert, PassThrough: args, @@ -76,11 +75,10 @@ func SSHCmd() *cobra.Command { }, } - cmd.Flags().StringVarP(&opts.User, "user", "u", "", "SSH login user (maps to ssh -l)") - cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Target host (required)") cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization)") cmd.Flags().IntVar(&opts.ResourceID, "resource-id", 0, "Resource ID for key signing (required)") - cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") + // Temporarily disable the exec flag to avoid confusion. + // cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") cmd.Args = cobra.ArbitraryArgs diff --git a/internal/api/types.go b/internal/api/types.go index 5f33927..7087460 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -286,6 +286,8 @@ type SignSSHKeyData struct { ValidAfter string `json:"validAfter"` ValidBefore string `json:"validBefore"` ExpiresInSeconds int `json:"expiresIn"` + Hostname string `json:"hostname"` // hostname for SSH connection (returned by API) + User string `json:"user"` // user for SSH connection (returned by API) } type SignSSHKeyResponse struct { From 94dbb65c3c7507c969a77df43872381cfd0272db Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 16:40:14 -0800 Subject: [PATCH 08/21] basic ssh working with org ca --- cmd/ssh/jit.go | 6 +++--- cmd/ssh/sign.go | 13 ++++++------- cmd/ssh/ssh.go | 24 +++++++++++------------- internal/api/client.go | 11 ++++------- internal/api/types.go | 9 +++++---- 5 files changed, 29 insertions(+), 34 deletions(-) diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go index 1d39bb9..4b39d5e 100644 --- a/cmd/ssh/jit.go +++ b/cmd/ssh/jit.go @@ -10,15 +10,15 @@ import ( // GenerateAndSignKey generates an Ed25519 key pair and signs the public key via the API. // Returns private key (PEM), public key (authorized_keys line), certificate, and sign response data. No files are written. -func GenerateAndSignKey(client *api.Client, orgID string, resourceID int) (privPEM, pubKey, cert string, signData *api.SignSSHKeyData, err error) { +func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (privPEM, pubKey, cert string, signData *api.SignSSHKeyData, err error) { privPEM, pubKey, err = sshkeys.GenerateKeyPair() if err != nil { return "", "", "", nil, fmt.Errorf("generate key pair: %w", err) } data, err := client.SignSSHKey(orgID, api.SignSSHKeyRequest{ - PublicKey: pubKey, - ResourceID: resourceID, + PublicKey: pubKey, + Resource: resourceID, }) if err != nil { return "", "", "", nil, fmt.Errorf("sign SSH key: %w", err) diff --git a/cmd/ssh/sign.go b/cmd/ssh/sign.go index 53a9219..3e9a14c 100644 --- a/cmd/ssh/sign.go +++ b/cmd/ssh/sign.go @@ -19,30 +19,30 @@ var errKeyFileRequired = errors.New("--key-file is required") func SignCmd() *cobra.Command { opts := struct { - OrgID string - ResourceID int + ResourceID string KeyFile string CertFile string }{} cmd := &cobra.Command{ - Use: "sign", + Use: "sign ", Short: "Generate and sign an SSH key, then save to files for use with system SSH.", Long: `Generates a key pair, signs the public key, and writes the private key and certificate to files.`, PreRunE: func(c *cobra.Command, args []string) error { if opts.KeyFile == "" { return errKeyFileRequired } - if opts.ResourceID == 0 { + if len(args) < 1 || args[0] == "" { return errResourceIDRequired } + opts.ResourceID = args[0] return nil }, Run: func(c *cobra.Command, args []string) { apiClient := api.FromContext(c.Context()) accountStore := config.AccountStoreFromContext(c.Context()) - orgID, err := ResolveOrgID(accountStore, opts.OrgID) + orgID, err := ResolveOrgID(accountStore, "") if err != nil { logger.Error("%v", err) os.Exit(1) @@ -100,9 +100,8 @@ func SignCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization)") - cmd.Flags().IntVar(&opts.ResourceID, "resource-id", 0, "Resource ID for key signing (required)") cmd.Flags().StringVar(&opts.KeyFile, "key-file", "", "Path to write the private key (required)") + cmd.Args = cobra.ExactArgs(1) cmd.Flags().StringVar(&opts.CertFile, "cert-file", "", "Path to write the certificate (default: -cert.pub)") return cmd diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 7d21011..b2e7364 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -12,32 +12,32 @@ import ( var ( errHostnameRequired = errors.New("API did not return a hostname for the connection") - errResourceIDRequired = errors.New("--resource-id is required to sign the SSH key") - errOrgRequired = errors.New("--org is required, or select an organization (pangolin select org)") + errResourceIDRequired = errors.New("resource (alias or identifier) is required") + errOrgRequired = errors.New("organization is required") ) func SSHCmd() *cobra.Command { opts := struct { - OrgID string - ResourceID int + ResourceID string Exec bool }{} cmd := &cobra.Command{ - Use: "ssh", + Use: "ssh [-- passthrough...]", Short: "Run an interactive SSH session", Long: `Run an SSH client in the terminal. Generates a key pair and signs it just-in-time, then connects to the target resource.`, PreRunE: func(c *cobra.Command, args []string) error { - if opts.ResourceID == 0 { + if len(args) < 1 || args[0] == "" { return errResourceIDRequired } + opts.ResourceID = args[0] return nil }, Run: func(c *cobra.Command, args []string) { apiClient := api.FromContext(c.Context()) accountStore := config.AccountStoreFromContext(c.Context()) - orgID, err := ResolveOrgID(accountStore, opts.OrgID) + orgID, err := ResolveOrgID(accountStore, "") if err != nil { logger.Error("%v", err) os.Exit(1) @@ -53,12 +53,13 @@ func SSHCmd() *cobra.Command { os.Exit(1) } + passThrough := args[1:] runOpts := RunOpts{ User: signData.User, Hostname: signData.Hostname, PrivateKeyPEM: privPEM, Certificate: cert, - PassThrough: args, + PassThrough: passThrough, } var exitCode int @@ -75,12 +76,9 @@ func SSHCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID (default: selected organization)") - cmd.Flags().IntVar(&opts.ResourceID, "resource-id", 0, "Resource ID for key signing (required)") - // Temporarily disable the exec flag to avoid confusion. - // cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") + cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") - cmd.Args = cobra.ArbitraryArgs + cmd.Args = cobra.MinimumNArgs(1) cmd.AddCommand(SignCmd()) diff --git a/internal/api/client.go b/internal/api/client.go index a0c82b0..9efcc53 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -360,17 +360,14 @@ func (c *Client) CheckOrgUserAccess(orgID, userID string) (*CheckOrgUserAccessRe return &response, nil } +// SignSSHKey signs an SSH public key for the given org and resource. func (c *Client) SignSSHKey(orgID string, req SignSSHKeyRequest) (*SignSSHKeyData, error) { path := fmt.Sprintf("/org/%s/ssh/sign-key", orgID) - var response SignSSHKeyResponse - err := c.Post(path, req, &response) - if err != nil { + var data SignSSHKeyData + if err := c.Post(path, req, &data); err != nil { return nil, err } - if !response.Success { - return nil, fmt.Errorf("ssh sign-key failed") - } - return &response.Data, nil + return &data, nil } // GetClient gets a client by ID diff --git a/internal/api/types.go b/internal/api/types.go index 7087460..dc444e9 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -275,8 +275,8 @@ type ApplyBlueprintResponse struct { } type SignSSHKeyRequest struct { - PublicKey string `json:"publicKey"` - ResourceID int `json:"resourceId"` + PublicKey string `json:"publicKey"` + Resource string `json:"resource"` } type SignSSHKeyData struct { @@ -286,11 +286,12 @@ type SignSSHKeyData struct { ValidAfter string `json:"validAfter"` ValidBefore string `json:"validBefore"` ExpiresInSeconds int `json:"expiresIn"` - Hostname string `json:"hostname"` // hostname for SSH connection (returned by API) - User string `json:"user"` // user for SSH connection (returned by API) + Hostname string `json:"sshHost"` // hostname for SSH connection (returned by API) + User string `json:"sshUsername"` // user for SSH connection (returned by API) } type SignSSHKeyResponse struct { Success bool `json:"success"` + Error *string `json:"error,omitempty"` Data SignSSHKeyData `json:"data"` } From 2f7b69bdb4349386099d90e0c51916a1b95fa759 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 20:36:58 -0800 Subject: [PATCH 09/21] add auth-daemon --- cmd/authdaemon/authdaemon.go | 152 +++++++++++++++++++++++++++++++++++ cmd/root.go | 4 +- 2 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 cmd/authdaemon/authdaemon.go diff --git a/cmd/authdaemon/authdaemon.go b/cmd/authdaemon/authdaemon.go new file mode 100644 index 0000000..8f11568 --- /dev/null +++ b/cmd/authdaemon/authdaemon.go @@ -0,0 +1,152 @@ +package authdaemon + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "runtime" + "syscall" + + "github.com/fosrl/cli/internal/logger" + authdaemonpkg "github.com/fosrl/newt/authdaemon" + "github.com/spf13/cobra" +) + +const ( + defaultPort = 22123 + defaultPrincipalsPath = "/var/run/auth-daemon/principals" + defaultCACertPath = "" + defaultSSHDConfigPath = "/etc/ssh/sshd_config" + defaultReloadSSHCommand = "" +) + +var ( + errPresharedKeyRequired = errors.New("pre-shared-key is required") + errRootRequired = errors.New("auth-daemon must be run as root (use sudo)") +) + +func AuthDaemonCmd() *cobra.Command { + opts := struct { + PreSharedKey string + Port int + PrincipalsFile string + CACertPath string + SSHDConfigPath string + ReloadSSHCommand string + }{} + + cmd := &cobra.Command{ + Use: "auth-daemon", + Short: "Start the auth daemon", + Long: "Start the auth daemon for remote SSH authentication", + PreRunE: func(c *cobra.Command, args []string) error { + if runtime.GOOS != "linux" { + return fmt.Errorf("auth-daemon is only supported on Linux, not %s", runtime.GOOS) + } + if opts.PreSharedKey == "" { + return errPresharedKeyRequired + } + if os.Geteuid() != 0 { + return errRootRequired + } + return nil + }, + Run: func(c *cobra.Command, args []string) { + runAuthDaemon(opts) + }, + } + + cmd.Flags().StringVar(&opts.PreSharedKey, "pre-shared-key", "", "Preshared key required for all API requests (required)") + cmd.MarkFlagRequired("pre-shared-key") + cmd.Flags().IntVar(&opts.Port, "port", defaultPort, "TCP listen port for the HTTPS server") + cmd.Flags().StringVar(&opts.PrincipalsFile, "principals-file", defaultPrincipalsPath, "Path to the principals file (one principal per line); used by SSH or other tools") + cmd.Flags().StringVar(&opts.CACertPath, "ca-cert-path", defaultCACertPath, "If set, write CA cert here on POST /connection when the file does not exist; PAM/OpenSSH use this") + cmd.Flags().StringVar(&opts.SSHDConfigPath, "sshd-config-path", defaultSSHDConfigPath, "Path to sshd_config when using CA cert (used with --ca-cert-path)") + cmd.Flags().StringVar(&opts.ReloadSSHCommand, "reload-ssh", defaultReloadSSHCommand, "Command to reload sshd after config change (e.g. \"systemctl reload sshd\"); empty = no reload") + + cmd.AddCommand(PrincipalsCmd()) + + return cmd +} + +// PrincipalsCmd returns the "principals" subcommand for use as AuthorizedPrincipalsCommand in sshd_config. +func PrincipalsCmd() *cobra.Command { + opts := struct { + PrincipalsFile string + Username string + }{} + + cmd := &cobra.Command{ + Use: "principals", + Short: "Output principals for a username (for AuthorizedPrincipalsCommand in sshd_config)", + Long: "Read the principals file and print principals that match the given username, one per line. Configure in sshd_config with AuthorizedPrincipalsCommand and %u for the username.", + PreRunE: func(c *cobra.Command, args []string) error { + if opts.PrincipalsFile == "" { + return errors.New("principals-file is required") + } + if opts.Username == "" { + return errors.New("username is required") + } + return nil + }, + Run: func(c *cobra.Command, args []string) { + runPrincipals(opts.PrincipalsFile, opts.Username) + }, + } + + cmd.Flags().StringVar(&opts.PrincipalsFile, "principals-file", defaultPrincipalsPath, "Path to the principals file written by the auth daemon") + cmd.Flags().StringVar(&opts.Username, "username", "", "Username to look up (e.g. from sshd %u)") + cmd.MarkFlagRequired("username") + + return cmd +} + +func runPrincipals(principalsPath, username string) { + list, err := authdaemonpkg.GetPrincipals(principalsPath, username) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + if len(list) == 0 { + fmt.Println("") + return + } + for _, principal := range list { + fmt.Println(principal) + } + return +} + +func runAuthDaemon(opts struct { + PreSharedKey string + Port int + PrincipalsFile string + CACertPath string + SSHDConfigPath string + ReloadSSHCommand string +}) { + cfg := authdaemonpkg.Config{ + Port: opts.Port, + PresharedKey: opts.PreSharedKey, + PrincipalsFilePath: opts.PrincipalsFile, + CACertPath: opts.CACertPath, + SSHDConfigPath: opts.SSHDConfigPath, + ReloadSSHCommand: opts.ReloadSSHCommand, + } + + srv, err := authdaemonpkg.NewServer(cfg) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := srv.Run(ctx); err != nil { + logger.Error("%v", err) + os.Exit(1) + } +} diff --git a/cmd/root.go b/cmd/root.go index 1e37410..2baca0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,9 +10,10 @@ import ( "github.com/fosrl/cli/cmd/auth" "github.com/fosrl/cli/cmd/auth/login" "github.com/fosrl/cli/cmd/auth/logout" + "github.com/fosrl/cli/cmd/authdaemon" "github.com/fosrl/cli/cmd/down" "github.com/fosrl/cli/cmd/logs" - selectcmd "github.com/fosrl/cli/cmd/select" + selectcmd "github.com/fosrl/cli/cmd/select" "github.com/fosrl/cli/cmd/ssh" "github.com/fosrl/cli/cmd/status" "github.com/fosrl/cli/cmd/up" @@ -43,6 +44,7 @@ func RootCommand(initResources bool) (*cobra.Command, error) { } cmd.AddCommand(auth.AuthCommand()) + cmd.AddCommand(authdaemon.AuthDaemonCmd()) cmd.AddCommand(apply.ApplyCommand()) cmd.AddCommand(selectcmd.SelectCmd()) cmd.AddCommand(up.UpCmd()) From 54f88c499e214d295d084826a23bb6e718bc0184 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 20:51:06 -0800 Subject: [PATCH 10/21] clean up --- cmd/authdaemon/authdaemon.go | 48 ++++++++++++++---------------------- go.mod | 5 ++-- go.sum | 4 --- 3 files changed, 22 insertions(+), 35 deletions(-) diff --git a/cmd/authdaemon/authdaemon.go b/cmd/authdaemon/authdaemon.go index 8f11568..0706ec0 100644 --- a/cmd/authdaemon/authdaemon.go +++ b/cmd/authdaemon/authdaemon.go @@ -15,11 +15,9 @@ import ( ) const ( - defaultPort = 22123 - defaultPrincipalsPath = "/var/run/auth-daemon/principals" - defaultCACertPath = "" - defaultSSHDConfigPath = "/etc/ssh/sshd_config" - defaultReloadSSHCommand = "" + defaultPort = 22123 + defaultPrincipalsPath = "/var/run/auth-daemon/principals" + defaultCACertPath = "/etc/ssh/ca.pem" ) var ( @@ -29,12 +27,10 @@ var ( func AuthDaemonCmd() *cobra.Command { opts := struct { - PreSharedKey string - Port int - PrincipalsFile string - CACertPath string - SSHDConfigPath string - ReloadSSHCommand string + PreSharedKey string + Port int + PrincipalsFile string + CACertPath string }{} cmd := &cobra.Command{ @@ -58,13 +54,11 @@ func AuthDaemonCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&opts.PreSharedKey, "pre-shared-key", "", "Preshared key required for all API requests (required)") + cmd.Flags().StringVar(&opts.PreSharedKey, "pre-shared-key", "", "Preshared key required for all requests to the auth daemon (required)") cmd.MarkFlagRequired("pre-shared-key") cmd.Flags().IntVar(&opts.Port, "port", defaultPort, "TCP listen port for the HTTPS server") - cmd.Flags().StringVar(&opts.PrincipalsFile, "principals-file", defaultPrincipalsPath, "Path to the principals file (one principal per line); used by SSH or other tools") - cmd.Flags().StringVar(&opts.CACertPath, "ca-cert-path", defaultCACertPath, "If set, write CA cert here on POST /connection when the file does not exist; PAM/OpenSSH use this") - cmd.Flags().StringVar(&opts.SSHDConfigPath, "sshd-config-path", defaultSSHDConfigPath, "Path to sshd_config when using CA cert (used with --ca-cert-path)") - cmd.Flags().StringVar(&opts.ReloadSSHCommand, "reload-ssh", defaultReloadSSHCommand, "Command to reload sshd after config change (e.g. \"systemctl reload sshd\"); empty = no reload") + cmd.Flags().StringVar(&opts.PrincipalsFile, "principals-file", defaultPrincipalsPath, "Path to the principals file") + cmd.Flags().StringVar(&opts.CACertPath, "ca-cert-path", defaultCACertPath, "Path to the CA certificate file") cmd.AddCommand(PrincipalsCmd()) @@ -83,16 +77,17 @@ func PrincipalsCmd() *cobra.Command { Short: "Output principals for a username (for AuthorizedPrincipalsCommand in sshd_config)", Long: "Read the principals file and print principals that match the given username, one per line. Configure in sshd_config with AuthorizedPrincipalsCommand and %u for the username.", PreRunE: func(c *cobra.Command, args []string) error { - if opts.PrincipalsFile == "" { - return errors.New("principals-file is required") - } if opts.Username == "" { return errors.New("username is required") } return nil }, Run: func(c *cobra.Command, args []string) { - runPrincipals(opts.PrincipalsFile, opts.Username) + path := opts.PrincipalsFile + if path == "" { + path = defaultPrincipalsPath + } + runPrincipals(path, opts.Username) }, } @@ -116,24 +111,19 @@ func runPrincipals(principalsPath, username string) { for _, principal := range list { fmt.Println(principal) } - return } func runAuthDaemon(opts struct { - PreSharedKey string - Port int - PrincipalsFile string - CACertPath string - SSHDConfigPath string - ReloadSSHCommand string + PreSharedKey string + Port int + PrincipalsFile string + CACertPath string }) { cfg := authdaemonpkg.Config{ Port: opts.Port, PresharedKey: opts.PreSharedKey, PrincipalsFilePath: opts.PrincipalsFile, CACertPath: opts.CACertPath, - SSHDConfigPath: opts.SSHDConfigPath, - ReloadSSHCommand: opts.ReloadSSHCommand, } srv, err := authdaemonpkg.NewServer(cfg) diff --git a/go.mod b/go.mod index f520217..f1ac5c5 100644 --- a/go.mod +++ b/go.mod @@ -78,5 +78,6 @@ require ( // If changes to Olm or Newt are required, use these // replace directives during development. // -//replace github.com/fosrl/olm => ../olm -//replace github.com/fosrl/newt => ../newt +replace github.com/fosrl/olm => ../olm + +replace github.com/fosrl/newt => ../newt diff --git a/go.sum b/go.sum index 0a635ad..2aa2cc5 100644 --- a/go.sum +++ b/go.sum @@ -50,10 +50,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/fosrl/newt v1.9.0 h1:66eJMo6fA+YcBTbddxTfNJXNQo1WWKzmn6zPRP5kSDE= -github.com/fosrl/newt v1.9.0/go.mod h1:d1+yYMnKqg4oLqAM9zdbjthjj2FQEVouiACjqU468ck= -github.com/fosrl/olm v1.4.1 h1:LRGt3ERfQaycqQFbjbPJ/xc9GIZqkEc+eEppzX6AtcQ= -github.com/fosrl/olm v1.4.1/go.mod h1:aC1oieI0tadd66zY7RDjXT3PPsR54mYS0FYMsHqFqs8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= From debe69387e999b1927eff61fc5211e62743c4770 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 20:55:52 -0800 Subject: [PATCH 11/21] set force to true --- cmd/authdaemon/authdaemon.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/authdaemon/authdaemon.go b/cmd/authdaemon/authdaemon.go index 0706ec0..ed223b1 100644 --- a/cmd/authdaemon/authdaemon.go +++ b/cmd/authdaemon/authdaemon.go @@ -124,6 +124,7 @@ func runAuthDaemon(opts struct { PresharedKey: opts.PreSharedKey, PrincipalsFilePath: opts.PrincipalsFile, CACertPath: opts.CACertPath, + Force: true, } srv, err := authdaemonpkg.NewServer(cfg) From 15438f25a367912332405d7aaa25d48b92a0d416 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 21:35:55 -0800 Subject: [PATCH 12/21] add force=true and custom port option --- cmd/ssh/runner_exec.go | 10 ++++++++-- cmd/ssh/runner_native.go | 12 ++++++++++-- cmd/ssh/ssh.go | 3 +++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/cmd/ssh/runner_exec.go b/cmd/ssh/runner_exec.go index de5ce8f..ad23ee3 100644 --- a/cmd/ssh/runner_exec.go +++ b/cmd/ssh/runner_exec.go @@ -7,6 +7,7 @@ import ( "os/exec" "os/signal" "runtime" + "strconv" "syscall" "github.com/creack/pty" @@ -56,9 +57,11 @@ func execExitCode(err error) int { // RunOpts is shared by both the exec and native SSH runners. // PrivateKeyPEM and Certificate are set just-in-time (JIT) before connect; no file paths. +// Port is optional: 0 means use default (22 or whatever is in Hostname); >0 overrides. type RunOpts struct { User string Hostname string + Port int // optional; 0 = default PrivateKeyPEM string // in-memory private key (PEM, OpenSSH format) Certificate string // in-memory certificate from sign-key API PassThrough []string @@ -81,7 +84,7 @@ func RunExec(opts RunOpts) (int, error) { defer cleanup() } - argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, keyPath, certPath, opts.PassThrough) + argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough) cmd := exec.Command(argv[0], argv[1:]...) usePTY := runtime.GOOS != "windows" && isatty.IsTerminal(os.Stdin.Fd()) @@ -147,7 +150,7 @@ func writeExecKeyFiles(opts RunOpts) (keyPath, certPath string, cleanup func(), return keyPath, certPath, cleanup, nil } -func buildExecSSHArgs(sshPath, user, hostname, keyPath, certPath string, passThrough []string) []string { +func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPath string, passThrough []string) []string { args := []string{sshPath} if user != "" { args = append(args, "-l", user) @@ -158,6 +161,9 @@ func buildExecSSHArgs(sshPath, user, hostname, keyPath, certPath string, passThr if certPath != "" { args = append(args, "-o", "CertificateFile="+certPath) } + if port > 0 { + args = append(args, "-p", strconv.Itoa(port)) + } args = append(args, hostname) args = append(args, passThrough...) return args diff --git a/cmd/ssh/runner_native.go b/cmd/ssh/runner_native.go index 2c637fd..bb304e8 100644 --- a/cmd/ssh/runner_native.go +++ b/cmd/ssh/runner_native.go @@ -7,6 +7,7 @@ import ( "os" "os/signal" "runtime" + "strconv" "strings" "syscall" @@ -20,7 +21,7 @@ const nativeDefaultSSHPort = "22" // RunNative runs an interactive SSH session using the pure-Go client (golang.org/x/crypto/ssh). // It does not use the system ssh binary. opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert). func RunNative(opts RunOpts) (int, error) { - addr, err := nativeSSHAddress(opts.Hostname) + addr, err := nativeSSHAddress(opts.Hostname, opts.Port) if err != nil { return 1, err } @@ -104,10 +105,17 @@ func looksLikeCertificate(data []byte) bool { strings.Contains(s, "ssh-rsa-cert") || strings.Contains(s, "ssh-ed25519-cert") || strings.Contains(s, "ecdsa-sha2-nistp256-cert") } -func nativeSSHAddress(hostname string) (string, error) { +func nativeSSHAddress(hostname string, port int) (string, error) { if hostname == "" { return "", errors.New("hostname is empty") } + host := hostname + if port > 0 { + if h, _, err := net.SplitHostPort(hostname); err == nil { + host = h + } + return net.JoinHostPort(host, strconv.Itoa(port)), nil + } if _, _, err := net.SplitHostPort(hostname); err == nil { return hostname, nil } diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index b2e7364..0db8936 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -20,6 +20,7 @@ func SSHCmd() *cobra.Command { opts := struct { ResourceID string Exec bool + Port int }{} cmd := &cobra.Command{ @@ -57,6 +58,7 @@ func SSHCmd() *cobra.Command { runOpts := RunOpts{ User: signData.User, Hostname: signData.Hostname, + Port: opts.Port, PrivateKeyPEM: privPEM, Certificate: cert, PassThrough: passThrough, @@ -77,6 +79,7 @@ func SSHCmd() *cobra.Command { } cmd.Flags().BoolVar(&opts.Exec, "exec", false, "Use system ssh binary instead of the built-in client") + cmd.Flags().IntVarP(&opts.Port, "port", "p", 0, "SSH port (default: 22)") cmd.Args = cobra.MinimumNArgs(1) From 8888f5e7582981626609d2354a182e53f51a882b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 16 Feb 2026 22:06:27 -0800 Subject: [PATCH 13/21] add message polling --- cmd/ssh/jit.go | 35 ++++++++++++++++++++++++++++++++--- internal/api/client.go | 10 ++++++++++ internal/api/types.go | 9 +++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go index 4b39d5e..6d0db05 100644 --- a/cmd/ssh/jit.go +++ b/cmd/ssh/jit.go @@ -2,29 +2,58 @@ package ssh import ( "fmt" + "time" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/sshkeys" ) +const ( + pollInitialDelay = 250 * time.Millisecond + pollStartInterval = 250 * time.Millisecond + pollBackoffSteps = 6 +) + // GenerateAndSignKey generates an Ed25519 key pair and signs the public key via the API. -// Returns private key (PEM), public key (authorized_keys line), certificate, and sign response data. No files are written. func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (privPEM, pubKey, cert string, signData *api.SignSSHKeyData, err error) { privPEM, pubKey, err = sshkeys.GenerateKeyPair() if err != nil { return "", "", "", nil, fmt.Errorf("generate key pair: %w", err) } - data, err := client.SignSSHKey(orgID, api.SignSSHKeyRequest{ + initResp, err := client.SignSSHKey(orgID, api.SignSSHKeyRequest{ PublicKey: pubKey, Resource: resourceID, }) if err != nil { return "", "", "", nil, fmt.Errorf("sign SSH key: %w", err) } + messageID := initResp.MessageID + if messageID == 0 { + return "", "", "", nil, fmt.Errorf("sign SSH key: API did not return a message ID") + } - return privPEM, pubKey, data.Certificate, data, nil + time.Sleep(pollInitialDelay) + + interval := pollStartInterval + for i := 0; i <= pollBackoffSteps; i++ { + msg, pollErr := client.GetRoundTripMessage(messageID) + if pollErr != nil { + return "", "", "", nil, fmt.Errorf("sign SSH key: poll: %w", pollErr) + } + if msg.Complete { + if msg.Error != nil && *msg.Error != "" { + return "", "", "", nil, fmt.Errorf("sign SSH key: %s", *msg.Error) + } + return privPEM, pubKey, initResp.Certificate, initResp, nil + } + if i < pollBackoffSteps { + time.Sleep(interval) + interval *= 2 + } + } + return "", "", "", nil, fmt.Errorf("sign SSH key: timed out waiting for round-trip message") } // ResolveOrgID returns orgID from the flag or the active account. Returns empty string and nil error if both are empty. diff --git a/internal/api/client.go b/internal/api/client.go index 9efcc53..a0f6c6e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -370,6 +370,16 @@ func (c *Client) SignSSHKey(orgID string, req SignSSHKeyRequest) (*SignSSHKeyDat return &data, nil } +// GetRoundTripMessage polls the round-trip message endpoint for status and optional result. +func (c *Client) GetRoundTripMessage(messageID int64) (*RoundTripMessage, error) { + path := fmt.Sprintf("/ws/round-trip-message/%d", messageID) + var msg RoundTripMessage + if err := c.Get(path, &msg); err != nil { + return nil, err + } + return &msg, nil +} + // GetClient gets a client by ID func (c *Client) GetClient(clientID int) (*GetClientResponse, error) { path := fmt.Sprintf("/client/%d", clientID) diff --git a/internal/api/types.go b/internal/api/types.go index dc444e9..4eed2b6 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -280,6 +280,7 @@ type SignSSHKeyRequest struct { } type SignSSHKeyData struct { + MessageID int64 `json:"messageId"` Certificate string `json:"certificate"` KeyID string `json:"keyId"` ValidPrincipals []string `json:"validPrincipals"` @@ -290,6 +291,14 @@ type SignSSHKeyData struct { User string `json:"sshUsername"` // user for SSH connection (returned by API) } +type RoundTripMessage struct { + MessageID int64 `json:"messageId"` + Complete bool `json:"complete"` + SentAt int64 `json:"sentAt"` // epoch seconds + ReceivedAt int64 `json:"receivedAt"` // epoch seconds + Error *string `json:"error,omitempty"` +} + type SignSSHKeyResponse struct { Success bool `json:"success"` Error *string `json:"error,omitempty"` From 560fa640b8d11091b2d6981e310562d0261460b6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 17 Feb 2026 11:41:36 -0800 Subject: [PATCH 14/21] check agent on select org and select account --- cmd/select/account/account.go | 13 ++++++++----- cmd/select/org/org.go | 22 +++++----------------- internal/utils/org.go | 5 +++++ 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/cmd/select/account/account.go b/cmd/select/account/account.go index 5389039..2189e0d 100644 --- a/cmd/select/account/account.go +++ b/cmd/select/account/account.go @@ -112,13 +112,16 @@ func accountMain(cmd *cobra.Command, opts *AccountCmdOpts) error { return err } - // Check if olmClient is running and if we need to shut it down + // Shut down running client only if it was started by this CLI olmClient := olm.NewClient("") if olmClient.IsRunning() { - logger.Info("Shutting down running client") - _, err := olmClient.Exit() - if err != nil { - logger.Warning("Failed to shut down OLM client: %s; you may need to do so manually.", err) + status, err := olmClient.GetStatus() + if err == nil && status != nil && status.Agent == olm.AgentName { + logger.Info("Shutting down running client") + _, err := olmClient.Exit() + if err != nil { + logger.Warning("Failed to shut down OLM client: %s; you may need to do so manually.", err) + } } } diff --git a/cmd/select/org/org.go b/cmd/select/org/org.go index 7bb9bd2..5faf467 100644 --- a/cmd/select/org/org.go +++ b/cmd/select/org/org.go @@ -96,29 +96,17 @@ func orgMain(cmd *cobra.Command, opts *OrgCmdOpts) error { } account.OrgID = selectedOrgID accountStore.Accounts[userID] = account - + if err := accountStore.Save(); err != nil { logger.Error("Failed to save account to store: %v", err) return err } - // Switch active client if running - utils.SwitchActiveClientOrg(selectedOrgID) - - // Check if olmClient is running and if we need to monitor a switch - olmClient := olm.NewClient("") - if olmClient.IsRunning() { - // Get current status - if it doesn't match the new org, monitor the switch - currentStatus, err := olmClient.GetStatus() - if err == nil && currentStatus != nil && currentStatus.OrgID != selectedOrgID { - // Switch was sent, monitor the switch process - monitorOrgSwitch(cfg.LogFile, selectedOrgID) - } else { - // Already on the correct org or no status available - logger.Success("Successfully selected organization: %s", selectedOrgID) - } + // Switch active client if running (and started by this CLI) + switched := utils.SwitchActiveClientOrg(selectedOrgID) + if switched { + monitorOrgSwitch(cfg.LogFile, selectedOrgID) } else { - // Client not running, no switch needed logger.Success("Successfully selected organization: %s", selectedOrgID) } diff --git a/internal/utils/org.go b/internal/utils/org.go index c144b4c..03a49a8 100644 --- a/internal/utils/org.go +++ b/internal/utils/org.go @@ -76,6 +76,11 @@ func SwitchActiveClientOrg(orgID string) bool { return false } + // Only switch if the client was started by this CLI + if currentStatus != nil && currentStatus.Agent != olm.AgentName { + return false + } + // If already on the target org, no need to switch if currentStatus != nil && currentStatus.OrgID == orgID { return false From a2767c8372892d33eb522f2cd8e6a2da5f8b294d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 17 Feb 2026 14:41:18 -0800 Subject: [PATCH 15/21] ensure client is running before ssh --- cmd/ssh/ssh.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 0db8936..1394116 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -7,13 +7,15 @@ import ( "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" "github.com/spf13/cobra" ) var ( errHostnameRequired = errors.New("API did not return a hostname for the connection") - errResourceIDRequired = errors.New("resource (alias or identifier) is required") - errOrgRequired = errors.New("organization is required") + errResourceIDRequired = errors.New("Resource (alias or identifier) is required") + errOrgRequired = errors.New("Organization is required") + errNoClientRunning = errors.New("No client is currently running. Start the client first with `pangolin up`") ) func SSHCmd() *cobra.Command { @@ -35,6 +37,12 @@ func SSHCmd() *cobra.Command { return nil }, Run: func(c *cobra.Command, args []string) { + client := olm.NewClient("") + if !client.IsRunning() { + logger.Error("%v", errNoClientRunning) + os.Exit(1) + } + apiClient := api.FromContext(c.Context()) accountStore := config.AccountStoreFromContext(c.Context()) From 90fd90423f43db88df8a4111a1bc498f27355356 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 17 Feb 2026 22:31:50 -0800 Subject: [PATCH 16/21] clean errors --- cmd/ssh/jit.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go index 6d0db05..7dbcd50 100644 --- a/cmd/ssh/jit.go +++ b/cmd/ssh/jit.go @@ -27,11 +27,11 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr Resource: resourceID, }) if err != nil { - return "", "", "", nil, fmt.Errorf("sign SSH key: %w", err) + return "", "", "", nil, fmt.Errorf("SSH error: %w", err) } messageID := initResp.MessageID if messageID == 0 { - return "", "", "", nil, fmt.Errorf("sign SSH key: API did not return a message ID") + return "", "", "", nil, fmt.Errorf("SSH error: API did not return a message ID") } time.Sleep(pollInitialDelay) @@ -40,11 +40,11 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr for i := 0; i <= pollBackoffSteps; i++ { msg, pollErr := client.GetRoundTripMessage(messageID) if pollErr != nil { - return "", "", "", nil, fmt.Errorf("sign SSH key: poll: %w", pollErr) + return "", "", "", nil, fmt.Errorf("SSH error: poll: %w", pollErr) } if msg.Complete { if msg.Error != nil && *msg.Error != "" { - return "", "", "", nil, fmt.Errorf("sign SSH key: %s", *msg.Error) + return "", "", "", nil, fmt.Errorf("SSH error: %s", *msg.Error) } return privPEM, pubKey, initResp.Certificate, initResp, nil } @@ -53,7 +53,7 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr interval *= 2 } } - return "", "", "", nil, fmt.Errorf("sign SSH key: timed out waiting for round-trip message") + return "", "", "", nil, fmt.Errorf("SSH error: timed out waiting for round-trip message") } // ResolveOrgID returns orgID from the flag or the active account. Returns empty string and nil error if both are empty. From 58aee8fe5711cc762da6feae7ffa03e36de8aacf Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Feb 2026 15:15:50 -0800 Subject: [PATCH 17/21] Use correct org_id --- internal/olm/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/olm/client.go b/internal/olm/client.go index 3c9e2af..fddcd6a 100644 --- a/internal/olm/client.go +++ b/internal/olm/client.go @@ -61,7 +61,7 @@ type ExitResponse struct { // SwitchOrgRequest represents the switch org request type SwitchOrgRequest struct { - OrgID string `json:"orgId"` + OrgID string `json:"org_id"` } // SwitchOrgResponse represents the switch org response From bb62fa8d664387b37f8648c7f0de31b424aca23d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 18 Feb 2026 15:39:09 -0800 Subject: [PATCH 18/21] Use a context to allow switch org to work --- cmd/up/client/client.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index 0c24805..6c9cddd 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -619,7 +619,14 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) if enableAPI { _ = olm.StartApi() } - olm.StartTunnel(tunnelConfig) + + // Run StartTunnel in a goroutine so org switching can restart it + // without causing the CLI process to exit + go olm.StartTunnel(tunnelConfig) + + // Block on context to keep process alive + <-ctx.Done() + logger.Info("Received shutdown signal, stopping tunnel") return nil } From a4428a5295c3a95aa8120a482c11faf491b3fb52 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 22 Feb 2026 16:18:38 -0800 Subject: [PATCH 19/21] bump version, bump olm version, update docs --- docs/pangolin.md | 4 +++- docs/pangolin_apply.md | 2 +- docs/pangolin_apply_blueprint.md | 2 +- docs/pangolin_auth-daemon.md | 28 +++++++++++++++++++++++++ docs/pangolin_auth-daemon_principals.md | 25 ++++++++++++++++++++++ docs/pangolin_auth.md | 2 +- docs/pangolin_auth_login.md | 2 +- docs/pangolin_auth_logout.md | 2 +- docs/pangolin_auth_status.md | 2 +- docs/pangolin_down.md | 2 +- docs/pangolin_down_client.md | 2 +- docs/pangolin_login.md | 2 +- docs/pangolin_logout.md | 2 +- docs/pangolin_logs.md | 2 +- docs/pangolin_logs_client.md | 2 +- docs/pangolin_select.md | 2 +- docs/pangolin_select_account.md | 2 +- docs/pangolin_select_org.md | 2 +- docs/pangolin_ssh.md | 26 +++++++++++++++++++++++ docs/pangolin_ssh_sign.md | 25 ++++++++++++++++++++++ docs/pangolin_status.md | 2 +- docs/pangolin_status_client.md | 2 +- docs/pangolin_up.md | 2 +- docs/pangolin_up_client.md | 2 +- docs/pangolin_update.md | 2 +- docs/pangolin_version.md | 2 +- go.mod | 2 +- internal/version/consts.go | 2 +- 28 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 docs/pangolin_auth-daemon.md create mode 100644 docs/pangolin_auth-daemon_principals.md create mode 100644 docs/pangolin_ssh.md create mode 100644 docs/pangolin_ssh_sign.md diff --git a/docs/pangolin.md b/docs/pangolin.md index f03379d..9437962 100644 --- a/docs/pangolin.md +++ b/docs/pangolin.md @@ -12,14 +12,16 @@ Pangolin CLI * [pangolin apply](pangolin_apply.md) - Apply commands * [pangolin auth](pangolin_auth.md) - Authentication commands +* [pangolin auth-daemon](pangolin_auth-daemon.md) - Start the auth daemon * [pangolin down](pangolin_down.md) - Stop a connection * [pangolin login](pangolin_login.md) - Login to Pangolin * [pangolin logout](pangolin_logout.md) - Logout from Pangolin * [pangolin logs](pangolin_logs.md) - View client logs * [pangolin select](pangolin_select.md) - Select account information to use +* [pangolin ssh](pangolin_ssh.md) - Run an interactive SSH session * [pangolin status](pangolin_status.md) - Status commands * [pangolin up](pangolin_up.md) - Start a connection * [pangolin update](pangolin_update.md) - Update Pangolin CLI to the latest version * [pangolin version](pangolin_version.md) - Print the version number -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_apply.md b/docs/pangolin_apply.md index 8a060b0..fd78250 100644 --- a/docs/pangolin_apply.md +++ b/docs/pangolin_apply.md @@ -17,4 +17,4 @@ Apply resources to the Pangolin server * [pangolin](pangolin.md) - Pangolin CLI * [pangolin apply blueprint](pangolin_apply_blueprint.md) - Apply a blueprint -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_apply_blueprint.md b/docs/pangolin_apply_blueprint.md index 2a8fc6b..e9332f4 100644 --- a/docs/pangolin_apply_blueprint.md +++ b/docs/pangolin_apply_blueprint.md @@ -22,4 +22,4 @@ pangolin apply blueprint [flags] * [pangolin apply](pangolin_apply.md) - Apply commands -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_auth-daemon.md b/docs/pangolin_auth-daemon.md new file mode 100644 index 0000000..581c3aa --- /dev/null +++ b/docs/pangolin_auth-daemon.md @@ -0,0 +1,28 @@ +## pangolin auth-daemon + +Start the auth daemon + +### Synopsis + +Start the auth daemon for remote SSH authentication + +``` +pangolin auth-daemon [flags] +``` + +### Options + +``` + --ca-cert-path string Path to the CA certificate file (default "/etc/ssh/ca.pem") + -h, --help help for auth-daemon + --port int TCP listen port for the HTTPS server (default 22123) + --pre-shared-key string Preshared key required for all requests to the auth daemon (required) + --principals-file string Path to the principals file (default "/var/run/auth-daemon/principals") +``` + +### SEE ALSO + +* [pangolin](pangolin.md) - Pangolin CLI +* [pangolin auth-daemon principals](pangolin_auth-daemon_principals.md) - Output principals for a username (for AuthorizedPrincipalsCommand in sshd_config) + +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_auth-daemon_principals.md b/docs/pangolin_auth-daemon_principals.md new file mode 100644 index 0000000..d877698 --- /dev/null +++ b/docs/pangolin_auth-daemon_principals.md @@ -0,0 +1,25 @@ +## pangolin auth-daemon principals + +Output principals for a username (for AuthorizedPrincipalsCommand in sshd_config) + +### Synopsis + +Read the principals file and print principals that match the given username, one per line. Configure in sshd_config with AuthorizedPrincipalsCommand and %u for the username. + +``` +pangolin auth-daemon principals [flags] +``` + +### Options + +``` + -h, --help help for principals + --principals-file string Path to the principals file written by the auth daemon (default "/var/run/auth-daemon/principals") + --username string Username to look up (e.g. from sshd %u) +``` + +### SEE ALSO + +* [pangolin auth-daemon](pangolin_auth-daemon.md) - Start the auth daemon + +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_auth.md b/docs/pangolin_auth.md index d39632e..90f43ce 100644 --- a/docs/pangolin_auth.md +++ b/docs/pangolin_auth.md @@ -19,4 +19,4 @@ Manage authentication and sessions * [pangolin auth logout](pangolin_auth_logout.md) - Logout from Pangolin * [pangolin auth status](pangolin_auth_status.md) - Check authentication status -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_auth_login.md b/docs/pangolin_auth_login.md index ec41ef8..6a808df 100644 --- a/docs/pangolin_auth_login.md +++ b/docs/pangolin_auth_login.md @@ -20,4 +20,4 @@ pangolin auth login [hostname] [flags] * [pangolin auth](pangolin_auth.md) - Authentication commands -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_auth_logout.md b/docs/pangolin_auth_logout.md index c0b4dee..38926a3 100644 --- a/docs/pangolin_auth_logout.md +++ b/docs/pangolin_auth_logout.md @@ -20,4 +20,4 @@ pangolin auth logout [flags] * [pangolin auth](pangolin_auth.md) - Authentication commands -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_auth_status.md b/docs/pangolin_auth_status.md index 6ec4ffa..5ccd242 100644 --- a/docs/pangolin_auth_status.md +++ b/docs/pangolin_auth_status.md @@ -20,4 +20,4 @@ pangolin auth status [flags] * [pangolin auth](pangolin_auth.md) - Authentication commands -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_down.md b/docs/pangolin_down.md index 9548971..9fec141 100644 --- a/docs/pangolin_down.md +++ b/docs/pangolin_down.md @@ -24,4 +24,4 @@ pangolin down [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin down client](pangolin_down_client.md) - Stop the client connection -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_down_client.md b/docs/pangolin_down_client.md index 305aa29..1c49301 100644 --- a/docs/pangolin_down_client.md +++ b/docs/pangolin_down_client.md @@ -20,4 +20,4 @@ pangolin down client [flags] * [pangolin down](pangolin_down.md) - Stop a connection -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_login.md b/docs/pangolin_login.md index 5da60b0..ed0453f 100644 --- a/docs/pangolin_login.md +++ b/docs/pangolin_login.md @@ -20,4 +20,4 @@ pangolin login [hostname] [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_logout.md b/docs/pangolin_logout.md index 8f7e275..d6d9dfd 100644 --- a/docs/pangolin_logout.md +++ b/docs/pangolin_logout.md @@ -20,4 +20,4 @@ pangolin logout [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_logs.md b/docs/pangolin_logs.md index 5722f6a..3451737 100644 --- a/docs/pangolin_logs.md +++ b/docs/pangolin_logs.md @@ -17,4 +17,4 @@ View and follow client logs * [pangolin](pangolin.md) - Pangolin CLI * [pangolin logs client](pangolin_logs_client.md) - View client logs -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_logs_client.md b/docs/pangolin_logs_client.md index bd24465..386512d 100644 --- a/docs/pangolin_logs_client.md +++ b/docs/pangolin_logs_client.md @@ -22,4 +22,4 @@ pangolin logs client [flags] * [pangolin logs](pangolin_logs.md) - View client logs -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_select.md b/docs/pangolin_select.md index 1b70d51..dbc67e1 100644 --- a/docs/pangolin_select.md +++ b/docs/pangolin_select.md @@ -18,4 +18,4 @@ Select account information to use * [pangolin select account](pangolin_select_account.md) - Select an account * [pangolin select org](pangolin_select_org.md) - Select an organization -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_select_account.md b/docs/pangolin_select_account.md index f3fcb79..f7871c4 100644 --- a/docs/pangolin_select_account.md +++ b/docs/pangolin_select_account.md @@ -22,4 +22,4 @@ pangolin select account [flags] * [pangolin select](pangolin_select.md) - Select account information to use -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_select_org.md b/docs/pangolin_select_org.md index fcf8604..8f2f4da 100644 --- a/docs/pangolin_select_org.md +++ b/docs/pangolin_select_org.md @@ -21,4 +21,4 @@ pangolin select org [flags] * [pangolin select](pangolin_select.md) - Select account information to use -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_ssh.md b/docs/pangolin_ssh.md new file mode 100644 index 0000000..5cbe35d --- /dev/null +++ b/docs/pangolin_ssh.md @@ -0,0 +1,26 @@ +## pangolin ssh + +Run an interactive SSH session + +### Synopsis + +Run an SSH client in the terminal. Generates a key pair and signs it just-in-time, then connects to the target resource. + +``` +pangolin ssh [-- passthrough...] [flags] +``` + +### Options + +``` + --exec Use system ssh binary instead of the built-in client + -h, --help help for ssh + -p, --port int SSH port (default: 22) +``` + +### SEE ALSO + +* [pangolin](pangolin.md) - Pangolin CLI +* [pangolin ssh sign](pangolin_ssh_sign.md) - Generate and sign an SSH key, then save to files for use with system SSH. + +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_ssh_sign.md b/docs/pangolin_ssh_sign.md new file mode 100644 index 0000000..5eb7c86 --- /dev/null +++ b/docs/pangolin_ssh_sign.md @@ -0,0 +1,25 @@ +## pangolin ssh sign + +Generate and sign an SSH key, then save to files for use with system SSH. + +### Synopsis + +Generates a key pair, signs the public key, and writes the private key and certificate to files. + +``` +pangolin ssh sign [flags] +``` + +### Options + +``` + --cert-file string Path to write the certificate (default: -cert.pub) + -h, --help help for sign + --key-file string Path to write the private key (required) +``` + +### SEE ALSO + +* [pangolin ssh](pangolin_ssh.md) - Run an interactive SSH session + +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_status.md b/docs/pangolin_status.md index 2c04840..e2acbeb 100644 --- a/docs/pangolin_status.md +++ b/docs/pangolin_status.md @@ -25,4 +25,4 @@ pangolin status [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin status client](pangolin_status_client.md) - Show client status -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_status_client.md b/docs/pangolin_status_client.md index 234391b..5e249cf 100644 --- a/docs/pangolin_status_client.md +++ b/docs/pangolin_status_client.md @@ -21,4 +21,4 @@ pangolin status client [flags] * [pangolin status](pangolin_status.md) - Status commands -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_up.md b/docs/pangolin_up.md index 5d07558..a0fdaf2 100644 --- a/docs/pangolin_up.md +++ b/docs/pangolin_up.md @@ -42,4 +42,4 @@ pangolin up [flags] * [pangolin](pangolin.md) - Pangolin CLI * [pangolin up client](pangolin_up_client.md) - Start a client connection -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_up_client.md b/docs/pangolin_up_client.md index c06108f..d52ec7e 100644 --- a/docs/pangolin_up_client.md +++ b/docs/pangolin_up_client.md @@ -38,4 +38,4 @@ pangolin up client [flags] * [pangolin up](pangolin_up.md) - Start a connection -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_update.md b/docs/pangolin_update.md index 02eabfb..c1a59ba 100644 --- a/docs/pangolin_update.md +++ b/docs/pangolin_update.md @@ -20,4 +20,4 @@ pangolin update [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/docs/pangolin_version.md b/docs/pangolin_version.md index 586c8f1..8a34bc9 100644 --- a/docs/pangolin_version.md +++ b/docs/pangolin_version.md @@ -20,4 +20,4 @@ pangolin version [flags] * [pangolin](pangolin.md) - Pangolin CLI -###### Auto generated by spf13/cobra on 27-Jan-2026 +###### Auto generated by spf13/cobra on 22-Feb-2026 diff --git a/go.mod b/go.mod index f1ac5c5..6cb72ae 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/fosrl/newt v1.9.0 - github.com/fosrl/olm v1.4.1 + github.com/fosrl/olm v1.4.2 github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 diff --git a/internal/version/consts.go b/internal/version/consts.go index 29f7d4d..a5d6e78 100644 --- a/internal/version/consts.go +++ b/internal/version/consts.go @@ -1,4 +1,4 @@ package version // Version is the current version of the Pangolin CLI -const Version = "0.3.3" +const Version = "0.4.0" From 490ea23cdaffb07b0c3e4b621eb1dec9d716307e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 22 Feb 2026 16:20:27 -0800 Subject: [PATCH 20/21] remove replace in go.mod --- go.mod | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6cb72ae..d7d0fdb 100644 --- a/go.mod +++ b/go.mod @@ -78,6 +78,5 @@ require ( // If changes to Olm or Newt are required, use these // replace directives during development. // -replace github.com/fosrl/olm => ../olm - -replace github.com/fosrl/newt => ../newt +// replace github.com/fosrl/olm => ../olm +// replace github.com/fosrl/newt => ../newt From 4fd373ecb56f128ee419a92cb1825d5565949fd8 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 22 Feb 2026 16:37:46 -0800 Subject: [PATCH 21/21] bump newt --- go.mod | 2 +- go.sum | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d7d0fdb..5272293 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 - github.com/fosrl/newt v1.9.0 + github.com/fosrl/newt v1.10.0 github.com/fosrl/olm v1.4.2 github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c diff --git a/go.sum b/go.sum index 2aa2cc5..eccdac2 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,10 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fosrl/newt v1.10.0 h1:k4bJGcUvGcyoO8QNBi5/RGbLpLC1ZUdau3Hecic+71A= +github.com/fosrl/newt v1.10.0/go.mod h1:d1+yYMnKqg4oLqAM9zdbjthjj2FQEVouiACjqU468ck= +github.com/fosrl/olm v1.4.2 h1:IyMbQvWyswaSaCuRDU1WnafImcOSxtRrYaofVeFgw3s= +github.com/fosrl/olm v1.4.2/go.mod h1:aC1oieI0tadd66zY7RDjXT3PPsR54mYS0FYMsHqFqs8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=