diff --git a/cmd/authdaemon/authdaemon.go b/cmd/authdaemon/authdaemon.go new file mode 100644 index 0000000..ed223b1 --- /dev/null +++ b/cmd/authdaemon/authdaemon.go @@ -0,0 +1,143 @@ +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 = "/etc/ssh/ca.pem" +) + +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 + }{} + + 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 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") + cmd.Flags().StringVar(&opts.CACertPath, "ca-cert-path", defaultCACertPath, "Path to the CA certificate file") + + 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.Username == "" { + return errors.New("username is required") + } + return nil + }, + Run: func(c *cobra.Command, args []string) { + path := opts.PrincipalsFile + if path == "" { + path = defaultPrincipalsPath + } + runPrincipals(path, 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) + } +} + +func runAuthDaemon(opts struct { + PreSharedKey string + Port int + PrincipalsFile string + CACertPath string +}) { + cfg := authdaemonpkg.Config{ + Port: opts.Port, + PresharedKey: opts.PreSharedKey, + PrincipalsFilePath: opts.PrincipalsFile, + CACertPath: opts.CACertPath, + Force: true, + } + + 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 5477578..2baca0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,9 +10,11 @@ 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" + "github.com/fosrl/cli/cmd/ssh" "github.com/fosrl/cli/cmd/status" "github.com/fosrl/cli/cmd/up" "github.com/fosrl/cli/cmd/update" @@ -42,11 +44,13 @@ 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()) 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/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/cmd/ssh/jit.go b/cmd/ssh/jit.go new file mode 100644 index 0000000..7dbcd50 --- /dev/null +++ b/cmd/ssh/jit.go @@ -0,0 +1,72 @@ +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. +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) + } + + initResp, err := client.SignSSHKey(orgID, api.SignSSHKeyRequest{ + PublicKey: pubKey, + Resource: resourceID, + }) + if err != nil { + return "", "", "", nil, fmt.Errorf("SSH error: %w", err) + } + messageID := initResp.MessageID + if messageID == 0 { + return "", "", "", nil, fmt.Errorf("SSH error: API did not return a message ID") + } + + time.Sleep(pollInitialDelay) + + interval := pollStartInterval + for i := 0; i <= pollBackoffSteps; i++ { + msg, pollErr := client.GetRoundTripMessage(messageID) + if pollErr != nil { + return "", "", "", nil, fmt.Errorf("SSH error: poll: %w", pollErr) + } + if msg.Complete { + if msg.Error != nil && *msg.Error != "" { + return "", "", "", nil, fmt.Errorf("SSH error: %s", *msg.Error) + } + return privPEM, pubKey, initResp.Certificate, initResp, nil + } + if i < pollBackoffSteps { + time.Sleep(interval) + interval *= 2 + } + } + 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. +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/runner_exec.go b/cmd/ssh/runner_exec.go new file mode 100644 index 0000000..ad23ee3 --- /dev/null +++ b/cmd/ssh/runner_exec.go @@ -0,0 +1,236 @@ +package ssh + +import ( + "errors" + "io" + "os" + "os/exec" + "os/signal" + "runtime" + "strconv" + "syscall" + + "github.com/creack/pty" + "github.com/mattn/go-isatty" + "golang.org/x/term" +) + +// 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 findExecSSHPath() (string, error) { + if path, err := exec.LookPath("ssh"); err == nil { + return path, nil + } + for _, p := range execSSHSearchPaths { + 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 execExitCode(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 1 +} + +// 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 +} + +// 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 + } + + keyPath, certPath, cleanup, err := writeExecKeyFiles(opts) + if err != nil { + return 1, err + } + if cleanup != nil { + defer cleanup() + } + + 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()) + + if usePTY { + return runExecWithPTY(cmd) + } + return runExecWithoutPTY(cmd) +} + +// 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) + } + } + return keyPath, certPath, cleanup, nil +} + +func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPath string, passThrough []string) []string { + args := []string{sshPath} + if user != "" { + args = append(args, "-l", user) + } + if keyPath != "" { + args = append(args, "-i", keyPath) + } + 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 +} + +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()) + 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 execExitCode(err), nil + } + return 0, nil +} + +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 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..bb304e8 --- /dev/null +++ b/cmd/ssh/runner_native.go @@ -0,0 +1,173 @@ +package ssh + +import ( + "errors" + "fmt" + "net" + "os" + "os/signal" + "runtime" + "strconv" + "strings" + "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. opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert). +func RunNative(opts RunOpts) (int, error) { + addr, err := nativeSSHAddress(opts.Hostname, opts.Port) + 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 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, 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 + } + return net.JoinHostPort(hostname, nativeDefaultSSHPort), nil +} + +func nativeSSHClientConfig(opts RunOpts) (*ssh.ClientConfig, error) { + if opts.PrivateKeyPEM == "" { + return nil, errors.New("private key required (JIT flow)") + } + + key := []byte(opts.PrivateKeyPEM) + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + if looksLikeCertificate(key) { + 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 := []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 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") + if user == "" { + user = os.Getenv("USERNAME") + } + if user == "" { + user = "root" + } + } + + return &ssh.ClientConfig{ + User: user, + 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/sign.go b/cmd/ssh/sign.go new file mode 100644 index 0000000..3e9a14c --- /dev/null +++ b/cmd/ssh/sign.go @@ -0,0 +1,166 @@ +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 { + ResourceID string + KeyFile string + CertFile 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 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, "") + 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 := 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@%s\n", keyPath, certPath, user, hostname) + fmt.Printf(" scp -i %q -o CertificateFile=%q ...\n", keyPath, certPath) + }, + } + + 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 +} + +// 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 new file mode 100644 index 0000000..1394116 --- /dev/null +++ b/cmd/ssh/ssh.go @@ -0,0 +1,97 @@ +package ssh + +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/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") + errNoClientRunning = errors.New("No client is currently running. Start the client first with `pangolin up`") +) + +func SSHCmd() *cobra.Command { + opts := struct { + ResourceID string + Exec bool + Port int + }{} + + cmd := &cobra.Command{ + 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 len(args) < 1 || args[0] == "" { + return errResourceIDRequired + } + opts.ResourceID = args[0] + 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()) + + orgID, err := ResolveOrgID(accountStore, "") + 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) + } + if signData == nil || signData.Hostname == "" { + logger.Error("%v", errHostnameRequired) + os.Exit(1) + } + + passThrough := args[1:] + runOpts := RunOpts{ + User: signData.User, + Hostname: signData.Hostname, + Port: opts.Port, + PrivateKeyPEM: privPEM, + Certificate: cert, + PassThrough: passThrough, + } + + var exitCode int + if opts.Exec { + exitCode, err = RunExec(runOpts) + } else { + exitCode, err = RunNative(runOpts) + } + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + os.Exit(exitCode) + }, + } + + 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) + + cmd.AddCommand(SignCmd()) + + return cmd +} 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 } 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 20b6f20..5272293 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,16 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 - github.com/fosrl/newt v1.9.0 - github.com/fosrl/olm v1.4.1 + github.com/creack/pty v1.1.24 + 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 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 ) require ( @@ -36,7 +40,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 @@ -57,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 @@ -76,5 +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 diff --git a/go.sum b/go.sum index 3e8a633..eccdac2 100644 --- a/go.sum +++ b/go.sum @@ -50,10 +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.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/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= @@ -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/api/client.go b/internal/api/client.go index 3e0be5f..a0f6c6e 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -360,6 +360,26 @@ 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 data SignSSHKeyData + if err := c.Post(path, req, &data); err != nil { + return nil, err + } + 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 46caf75..4eed2b6 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -273,3 +273,34 @@ type ApplyBlueprintResponse struct { Succeeded bool `json:"succeeded"` Contents string `json:"contents"` } + +type SignSSHKeyRequest struct { + PublicKey string `json:"publicKey"` + Resource string `json:"resource"` +} + +type SignSSHKeyData struct { + MessageID int64 `json:"messageId"` + Certificate string `json:"certificate"` + KeyID string `json:"keyId"` + ValidPrincipals []string `json:"validPrincipals"` + ValidAfter string `json:"validAfter"` + ValidBefore string `json:"validBefore"` + ExpiresInSeconds int `json:"expiresIn"` + Hostname string `json:"sshHost"` // hostname for SSH connection (returned by API) + 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"` + Data SignSSHKeyData `json:"data"` +} 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 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 +} 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 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"