diff --git a/cmd/root.go b/cmd/root.go index 94ef259..add06f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/fosrl/cli/cmd/down" "github.com/fosrl/cli/cmd/list" "github.com/fosrl/cli/cmd/logs" + "github.com/fosrl/cli/cmd/scp" selectcmd "github.com/fosrl/cli/cmd/select" "github.com/fosrl/cli/cmd/ssh" "github.com/fosrl/cli/cmd/status" @@ -67,6 +68,7 @@ func RootCommand(initResources bool) (*cobra.Command, error) { } cmd.AddCommand(ssh.SSHCmd()) + cmd.AddCommand(scp.SCPCmd()) cmd.AddCommand(update.UpdateCmd()) cmd.AddCommand(version.VersionCmd()) cmd.AddCommand(login.LoginCmd()) diff --git a/cmd/scp/connect.go b/cmd/scp/connect.go new file mode 100644 index 0000000..85b4cae --- /dev/null +++ b/cmd/scp/connect.go @@ -0,0 +1,123 @@ +package scp + +import ( + "fmt" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/fosrl/cli/internal/olm" +) + +const ( + siteAppearTimeout = 15 * time.Second + siteConnectTimeout = 30 * time.Second + pollInterval = 500 * time.Millisecond +) + +// siteConnectedMsg is sent to the bubbletea program when any site connects. +type siteConnectedMsg struct{} + +// siteConnectTimedOutMsg is sent when the connection poll deadline is exceeded. +type siteConnectTimedOutMsg struct{} + +// connectSpinnerModel is a minimal bubbletea model that displays a spinner +// while a background goroutine polls for the site connection. +type connectSpinnerModel struct { + spinner spinner.Model + timedOut bool +} + +func newConnectSpinnerModel() connectSpinnerModel { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // cyan + return connectSpinnerModel{spinner: s} +} + +func (m connectSpinnerModel) Init() tea.Cmd { + return m.spinner.Tick +} + +func (m connectSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg.(type) { + case siteConnectedMsg: + return m, tea.Quit + case siteConnectTimedOutMsg: + m.timedOut = true + return m, tea.Quit + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd +} + +func (m connectSpinnerModel) View() string { + return fmt.Sprintf("%s Connecting...\n", m.spinner.View()) +} + +// waitForAnySiteConnection waits for at least one site from siteIDs to appear +// in the olm status output and become connected. +func waitForAnySiteConnection(client *olm.Client, siteIDs []int) error { + deadline := time.Now().Add(siteAppearTimeout) + appearedIDs := map[int]bool{} + anyConnected := false + + for time.Now().Before(deadline) { + status, err := client.GetStatus() + if err == nil { + for _, siteID := range siteIDs { + if peer, ok := status.PeerStatuses[siteID]; ok { + appearedIDs[siteID] = true + if peer.Connected { + anyConnected = true + } + } + } + } + if len(appearedIDs) > 0 { + break + } + time.Sleep(pollInterval) + } + + if len(appearedIDs) == 0 { + return fmt.Errorf("no sites were added to the connection; the JIT connect request may have failed") + } + + if anyConnected { + return nil + } + + model := newConnectSpinnerModel() + program := tea.NewProgram(model) + + go func() { + deadline := time.Now().Add(siteConnectTimeout) + for time.Now().Before(deadline) { + status, err := client.GetStatus() + if err == nil { + for siteID := range appearedIDs { + if peer, ok := status.PeerStatuses[siteID]; ok && peer.Connected { + program.Send(siteConnectedMsg{}) + return + } + } + } + time.Sleep(pollInterval) + } + program.Send(siteConnectTimedOutMsg{}) + }() + + finalModel, err := program.Run() + if err != nil { + return fmt.Errorf("spinner error: %w", err) + } + + if finalModel.(connectSpinnerModel).timedOut { + return fmt.Errorf("Timed out waiting for site to connect. Please disconnect (down) then reconnect (up) the client and try again.") + } + + return nil +} diff --git a/cmd/scp/exec_args.go b/cmd/scp/exec_args.go new file mode 100644 index 0000000..0c009f0 --- /dev/null +++ b/cmd/scp/exec_args.go @@ -0,0 +1,94 @@ +package scp + +import ( + "fmt" + "runtime" + "strconv" + "strings" +) + +func buildExecSCPArgs(scpPath string, opts RunOpts, keyPath, certPath string) []string { + args := []string{scpPath} + if keyPath != "" { + args = append(args, "-i", keyPath) + } + if certPath != "" { + args = append(args, "-o", "CertificateFile="+certPath) + } + args = append(args, + "-o", "PubkeyAuthentication=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "IdentitiesOnly=yes", + "-o", "PasswordAuthentication=no", + "-o", "KbdInteractiveAuthentication=no", + ) + args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR") + if opts.Port > 0 { + args = append(args, "-P", strconv.Itoa(opts.Port)) + } + args = append(args, opts.Passthrough.Options...) + args = append(args, rewriteSCPOperands(opts)...) + return args +} + +func rewriteSCPOperands(opts RunOpts) []string { + if len(opts.Passthrough.RemoteCommand) == 0 { + return nil + } + rewritten := make([]string, 0, len(opts.Passthrough.RemoteCommand)) + for _, operand := range opts.Passthrough.RemoteCommand { + rewritten = append(rewritten, rewriteSCPOperand(operand, opts.ResourceID, opts.User, opts.Hostname)) + } + return rewritten +} + +func rewriteSCPOperand(operand, resourceID, user, hostname string) string { + hostSpec, pathPart, ok := splitSCPOperand(operand) + if !ok { + return operand + } + if !matchesTargetHost(hostSpec, resourceID) { + return operand + } + return fmt.Sprintf("%s:%s", hostWithUser(hostname, user), pathPart) +} + +func splitSCPOperand(s string) (hostSpec string, pathPart string, ok bool) { + if s == "" { + return "", "", false + } + if runtime.GOOS == "windows" { + if len(s) >= 2 && s[1] == ':' { + if len(s) == 2 { + return "", "", false + } + next := s[2] + if next == '\\' || next == '/' { + return "", "", false + } + } + } + idx := strings.IndexByte(s, ':') + if idx <= 0 || idx == len(s)-1 { + return "", "", false + } + return s[:idx], s[idx+1:], true +} + +func matchesTargetHost(hostSpec, resourceID string) bool { + hostOnly := hostSpec + if u, h, hasAt := strings.Cut(hostSpec, "@"); hasAt { + if u == "" || h == "" { + return false + } + hostOnly = h + } + return hostOnly == resourceID +} + +func hostWithUser(hostname, user string) string { + if user == "" { + return hostname + } + return user + "@" + hostname +} diff --git a/cmd/scp/exec_scp_env.go b/cmd/scp/exec_scp_env.go new file mode 100644 index 0000000..c8d95b5 --- /dev/null +++ b/cmd/scp/exec_scp_env.go @@ -0,0 +1,17 @@ +package scp + +import ( + "os" + "strings" +) + +// envSCPBinary overrides the scp(1) executable used by RunExec on all platforms when non-empty. +const envSCPBinary = "PANGOLIN_SCP_BINARY" + +func scpBinaryFromEnv() (path string, ok bool) { + p := strings.TrimSpace(os.Getenv(envSCPBinary)) + if p == "" { + return "", false + } + return p, true +} diff --git a/cmd/scp/runner_exec_unix.go b/cmd/scp/runner_exec_unix.go new file mode 100644 index 0000000..1ea10d0 --- /dev/null +++ b/cmd/scp/runner_exec_unix.go @@ -0,0 +1,136 @@ +//go:build !windows +// +build !windows + +package scp + +import ( + "errors" + "fmt" + "os" + "os/exec" +) + +// execSCPSearchPaths are fallback locations for the scp executable when not in PATH. +var execSCPSearchPaths = []string{ + "/usr/bin/scp", + "/usr/local/bin/scp", + `C:\\Windows\\System32\\OpenSSH\\scp.exe`, +} + +func findExecSCPPath() (string, error) { + if p, ok := scpBinaryFromEnv(); ok { + if isExecutable(p) { + return p, nil + } + return "", fmt.Errorf("%s=%q: not an executable file", envSCPBinary, p) + } + if path, err := exec.LookPath("scp"); err == nil { + return path, nil + } + for _, p := range execSCPSearchPaths { + if isExecutable(p) { + return p, nil + } + } + return "", errors.New("scp 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 + } + 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 +} + +// RunExec runs scp via the system scp binary. opts.PrivateKeyPEM and opts.Certificate +// must be set (JIT key + signed cert). +func RunExec(opts RunOpts) (int, error) { + scpPath, err := findExecSCPPath() + if err != nil { + return 1, err + } + + keyPath, certPath, cleanup, err := writeExecKeyFiles(opts) + if err != nil { + return 1, err + } + if cleanup != nil { + defer cleanup() + } + + argv := buildExecSCPArgs(scpPath, opts, keyPath, certPath) + cmd := exec.Command(argv[0], argv[1:]...) + 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 +} + +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 +} diff --git a/cmd/scp/runner_exec_windows.go b/cmd/scp/runner_exec_windows.go new file mode 100644 index 0000000..996db26 --- /dev/null +++ b/cmd/scp/runner_exec_windows.go @@ -0,0 +1,176 @@ +//go:build windows +// +build windows + +package scp + +import ( + "errors" + "fmt" + "os" + "os/exec" + + "golang.org/x/sys/windows" +) + +// execSCPSearchPaths are fallback locations for the scp executable on Windows. +var execSCPSearchPaths = []string{ + `C:\\Windows\\System32\\OpenSSH\\scp.exe`, +} + +func findExecSCPPathWindows() (string, error) { + if p, ok := scpBinaryFromEnv(); ok { + info, err := os.Stat(p) + if err != nil { + return "", fmt.Errorf("%s=%q: %w", envSCPBinary, p, err) + } + if info.IsDir() { + return "", fmt.Errorf("%s=%q: is a directory", envSCPBinary, p) + } + return p, nil + } + if path, err := exec.LookPath("scp"); err == nil { + return path, nil + } + for _, p := range execSCPSearchPaths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", errors.New("scp executable not found in PATH or in OpenSSH location (C:\\Windows\\System32\\OpenSSH\\scp.exe)") +} + +func execExitCode(err error) int { + if err == nil { + return 0 + } + if exitErr, ok := err.(*exec.ExitError); ok { + return exitErr.ExitCode() + } + return 1 +} + +// RunExec runs scp via the system scp binary. opts.PrivateKeyPEM and opts.Certificate +// must be set (JIT key + signed cert). +func RunExec(opts RunOpts) (int, error) { + scpPath, err := findExecSCPPathWindows() + if err != nil { + return 1, err + } + + keyPath, certPath, cleanup, err := writeExecKeyFilesWindows(opts) + if err != nil { + return 1, err + } + if cleanup != nil { + defer cleanup() + } + + argv := buildExecSCPArgs(scpPath, opts, keyPath, certPath) + cmd := exec.Command(argv[0], argv[1:]...) + 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 +} + +// setWindowsFileOwnerOnly sets the file's ACL so that only the current user has access. +func setWindowsFileOwnerOnly(path string) error { + var token windows.Token + proc := windows.CurrentProcess() + err := windows.OpenProcessToken(proc, windows.TOKEN_QUERY, &token) + if err != nil { + return err + } + defer token.Close() + + tokenUser, err := token.GetTokenUser() + if err != nil { + return err + } + userSID := tokenUser.User.Sid + + access := []windows.EXPLICIT_ACCESS{ + { + AccessPermissions: windows.GENERIC_ALL, + AccessMode: windows.SET_ACCESS, + Inheritance: windows.NO_INHERITANCE, + Trustee: windows.TRUSTEE{ + TrusteeForm: windows.TRUSTEE_IS_SID, + TrusteeType: windows.TRUSTEE_IS_USER, + TrusteeValue: windows.TrusteeValueFromSID(userSID), + }, + }, + } + + acl, err := windows.ACLFromEntries(access, nil) + if err != nil { + return err + } + + secInfo := windows.SECURITY_INFORMATION(windows.OWNER_SECURITY_INFORMATION | windows.DACL_SECURITY_INFORMATION | windows.PROTECTED_DACL_SECURITY_INFORMATION) + return windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + secInfo, + userSID, + nil, + acl, + nil, + ) +} + +func writeExecKeyFilesWindows(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.Close(); err != nil { + os.Remove(keyFile.Name()) + return "", "", nil, err + } + + if err := setWindowsFileOwnerOnly(keyFile.Name()); 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 +} diff --git a/cmd/scp/runner_opts.go b/cmd/scp/runner_opts.go new file mode 100644 index 0000000..92d94b5 --- /dev/null +++ b/cmd/scp/runner_opts.go @@ -0,0 +1,13 @@ +package scp + +import sshcmd "github.com/fosrl/cli/cmd/ssh" + +type RunOpts struct { + User string + Hostname string + Port int + PrivateKeyPEM string + Certificate string + ResourceID string + Passthrough sshcmd.SSHPassthrough +} diff --git a/cmd/scp/scp.go b/cmd/scp/scp.go new file mode 100644 index 0000000..4679a95 --- /dev/null +++ b/cmd/scp/scp.go @@ -0,0 +1,131 @@ +package scp + +import ( + "errors" + "os" + + sshcmd "github.com/fosrl/cli/cmd/ssh" + "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/fosrl/cli/internal/utils" + "github.com/spf13/cobra" +) + +var ( + errHostnameRequired = errors.New("API did not return a hostname for the connection") + errNoClientRunning = errors.New("No client is currently running. Start the client first.") + errScpOperands = errors.New("scp requires at least one remote operand; example: pangolin scp ./local-file my-server.internal:/remote/path") + errNoRemoteOperand = errors.New("no remote operand found; at least one of source or destination must be a remote path (host:path or user@host:path)") +) + +func SCPCmd() *cobra.Command { + opts := struct { + ResourceID string + Username string + Port int + }{} + + cmd := &cobra.Command{ + Use: "scp [flags] ", + Short: "Run scp using just-in-time SSH certificates", + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, // Forward unknown flags to system scp(1) + }, + Long: `Run scp(1) in the terminal. Generates a key pair and signs it just-in-time, then executes the system OpenSSH scp client. + +Use the resource alias or identifier as the host in remote operands, exactly as you would with regular scp. +The resource alias is resolved to the connected hostname transparently. +Examples: + pangolin scp ./local-file my-server.internal:/remote/path + pangolin scp my-server.internal:/var/log/syslog ./syslog + pangolin scp -r ./dir my-server.internal:~/ + +Set PANGOLIN_SCP_BINARY to the full path of scp(1) to override PATH lookup on all platforms.`, + PreRunE: func(c *cobra.Command, args []string) error { + if len(args) < 2 { + return errScpOperands + } + username, resourceID, found := parseSCPRemoteHost(args) + if !found { + return errNoRemoteOperand + } + opts.Username = username + opts.ResourceID = resourceID + 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()) + + // init a jit connection to the site if we need to because we might not be connected + _, err := client.JITConnectByResourceID(opts.ResourceID) + if err != nil { + logger.Warning("%v", err) // keep warning behavior for backward compatibility + } + + orgID, err := utils.ResolveOrgID(accountStore, "") + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + + privPEM, _, cert, signData, err := sshcmd.GenerateAndSignKey(apiClient, orgID, opts.ResourceID, opts.Username) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + if signData == nil || signData.Hostname == "" { + logger.Error("%v", errHostnameRequired) + os.Exit(1) + } + + siteIDs := []int{} + if signData.SiteID != 0 { + siteIDs = append(siteIDs, signData.SiteID) + } + for _, id := range signData.SiteIDs { + if id != 0 { + siteIDs = append(siteIDs, id) + } + } + + if len(siteIDs) > 0 { + if err := waitForAnySiteConnection(client, siteIDs); err != nil { + logger.Error("%v", err) + os.Exit(1) + } + } + + pt := sshcmd.ParseOpenSSHPassThrough(args) + + runOpts := RunOpts{ + User: signData.User, + Hostname: signData.Hostname, + Port: opts.Port, + PrivateKeyPEM: privPEM, + Certificate: cert, + ResourceID: opts.ResourceID, + Passthrough: pt, + } + + exitCode, err := RunExec(runOpts) + if err != nil { + logger.Error("%v", err) + os.Exit(1) + } + os.Exit(exitCode) + }, + } + + cmd.Flags().IntVarP(&opts.Port, "port", "p", 0, "Remote SCP/SSH port (default: 22)") + + return cmd +} diff --git a/cmd/scp/scp_osargs.go b/cmd/scp/scp_osargs.go new file mode 100644 index 0000000..247e88a --- /dev/null +++ b/cmd/scp/scp_osargs.go @@ -0,0 +1,22 @@ +package scp + +import "strings" + +// parseSCPRemoteHost scans scp operands and returns the username and resource ID +// from the first remote operand (host:path or user@host:path). Local paths are skipped. +func parseSCPRemoteHost(args []string) (username, resourceID string, found bool) { + for _, arg := range args { + hostSpec, _, ok := splitSCPOperand(arg) + if !ok { + continue + } + if u, h, hasAt := strings.Cut(hostSpec, "@"); hasAt { + if u != "" && h != "" { + return u, h, true + } + } else { + return "", hostSpec, true + } + } + return "", "", false +} diff --git a/cmd/ssh/exec_args.go b/cmd/ssh/exec_args.go index 9673539..bcfa6eb 100644 --- a/cmd/ssh/exec_args.go +++ b/cmd/ssh/exec_args.go @@ -16,6 +16,18 @@ func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPat if certPath != "" { args = append(args, "-o", "CertificateFile="+certPath) } + // JIT cert-based auth should not fall back to interactive password prompts. + args = append(args, + "-o", "PubkeyAuthentication=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "IdentitiesOnly=yes", + "-o", "PasswordAuthentication=no", + "-o", "KbdInteractiveAuthentication=no", + ) + // The built-in SSH server generates a fresh ephemeral host key on every + // restart, so skip known_hosts checking to avoid spurious MITM warnings. + // LogLevel=ERROR suppresses the "Permanently added ..." informational line. + args = append(args, "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "LogLevel=ERROR") if port > 0 { args = append(args, "-p", strconv.Itoa(port)) } diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go index 6f9917f..41b6be0 100644 --- a/cmd/ssh/jit.go +++ b/cmd/ssh/jit.go @@ -1,11 +1,14 @@ package ssh import ( + "bytes" "fmt" + "strings" "time" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/sshkeys" + "golang.org/x/crypto/ssh" ) const ( @@ -14,8 +17,36 @@ const ( pollBackoffSteps = 6 ) +func validateSignedCert(pubKey, cert string) error { + cert = strings.TrimSpace(cert) + if cert == "" { + return fmt.Errorf("API returned an empty SSH certificate") + } + + pubParsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubKey)) + if err != nil { + return fmt.Errorf("parse generated public key: %w", err) + } + + certParsed, _, _, _, err := ssh.ParseAuthorizedKey([]byte(cert)) + if err != nil { + return fmt.Errorf("parse returned certificate: %w", err) + } + + certKey, ok := certParsed.(*ssh.Certificate) + if !ok { + return fmt.Errorf("API returned %q instead of an SSH certificate", certParsed.Type()) + } + + if !bytes.Equal(certKey.Key.Marshal(), pubParsed.Marshal()) { + return fmt.Errorf("returned certificate does not match generated key") + } + + return nil +} + // 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) { +func GenerateAndSignKey(client *api.Client, orgID string, resourceID string, username 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) @@ -24,6 +55,7 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr initResp, err := client.SignSSHKey(orgID, api.SignSSHKeyRequest{ PublicKey: pubKey, Resource: resourceID, + Username: username, }) if err != nil { return "", "", "", nil, fmt.Errorf("SSH error: %w", err) @@ -36,7 +68,11 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr } else if initResp.MessageID != 0 { messageIDs = []int64{initResp.MessageID} } else { - return "", "", "", nil, fmt.Errorf("SSH error: API did not return a message ID") + if err := validateSignedCert(pubKey, initResp.Certificate); err != nil { + return "", "", "", nil, fmt.Errorf("SSH error: invalid certificate: %w", err) + } + // return the data as this is okay + return privPEM, pubKey, initResp.Certificate, initResp, nil } time.Sleep(pollInitialDelay) @@ -52,6 +88,9 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr if msg.Error != nil && *msg.Error != "" { return "", "", "", nil, fmt.Errorf("SSH error: %s", *msg.Error) } + if err := validateSignedCert(pubKey, initResp.Certificate); err != nil { + return "", "", "", nil, fmt.Errorf("SSH error: invalid certificate: %w", err) + } return privPEM, pubKey, initResp.Certificate, initResp, nil } } diff --git a/cmd/ssh/runner_exec_unix.go b/cmd/ssh/runner_exec_unix.go index c25ef07..048fe26 100644 --- a/cmd/ssh/runner_exec_unix.go +++ b/cmd/ssh/runner_exec_unix.go @@ -10,7 +10,6 @@ import ( "os" "os/exec" "os/signal" - "runtime" "syscall" "github.com/creack/pty" @@ -48,9 +47,6 @@ func isExecutable(path string) bool { if err != nil || info.IsDir() { return false } - if runtime.GOOS == "windows" { - return true - } return info.Mode()&0o111 != 0 } @@ -84,7 +80,7 @@ func RunExec(opts RunOpts) (int, error) { argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.SSHPassthrough) cmd := exec.Command(argv[0], argv[1:]...) - usePTY := runtime.GOOS != "windows" && isatty.IsTerminal(os.Stdin.Fd()) + usePTY := isatty.IsTerminal(os.Stdin.Fd()) if usePTY { return runExecWithPTY(cmd) diff --git a/cmd/ssh/sign.go b/cmd/ssh/sign.go index 671fef1..9cd18e5 100644 --- a/cmd/ssh/sign.go +++ b/cmd/ssh/sign.go @@ -48,7 +48,7 @@ func SignCmd() *cobra.Command { os.Exit(1) } - privPEM, _, cert, signData, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID) + privPEM, _, cert, signData, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID, "") // username is not used because we are signing a key which means we are using push mode if err != nil { logger.Error("%v", err) os.Exit(1) diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 6880d48..85fd342 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -3,6 +3,7 @@ package ssh import ( "errors" "os" + "strings" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" @@ -13,20 +14,22 @@ import ( ) var ( - errHostnameRequired = errors.New("API did not return a hostname for the connection") - errResourceIDRequired = errors.New("Resource (alias or identifier) is required; example: pangolin ssh my-server.internal") - errNoClientRunning = errors.New("No client is currently running. Start the client first.") + errHostnameRequired = errors.New("API did not return a hostname for the connection") + errResourceIDRequired = errors.New("Resource (alias or identifier) is required; example: pangolin ssh my-server.internal or pangolin ssh user@my-server.internal") + errNoClientRunning = errors.New("No client is currently running. Start the client first.") ) func SSHCmd() *cobra.Command { opts := struct { - ResourceID string - Builtin bool - Port int + ResourceID string + Username string + TargetArgRaw string + Builtin bool + Port int }{} cmd := &cobra.Command{ - Use: "ssh ", + Use: "ssh ", Short: "Run an interactive SSH session", FParseErrWhitelist: cobra.FParseErrWhitelist{ UnknownFlags: true, // -L, -R, and other ssh(1) flags are forwarded to the system OpenSSH client @@ -40,7 +43,18 @@ Set PANGOLIN_SSH_BINARY to the full path of ssh(1) to override PATH lookup on al if len(args) < 1 || args[0] == "" { return errResourceIDRequired } - opts.ResourceID = args[0] + + opts.TargetArgRaw = args[0] + if user, resource, hasAt := strings.Cut(args[0], "@"); hasAt { + if resource == "" { + return errResourceIDRequired + } + opts.Username = user + opts.ResourceID = resource + } else { + opts.Username = "" + opts.ResourceID = args[0] + } return nil }, Run: func(c *cobra.Command, args []string) { @@ -65,7 +79,7 @@ Set PANGOLIN_SSH_BINARY to the full path of ssh(1) to override PATH lookup on al os.Exit(1) } - privPEM, _, cert, signData, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID) + privPEM, _, cert, signData, err := GenerateAndSignKey(apiClient, orgID, opts.ResourceID, opts.Username) if err != nil { logger.Error("%v", err) os.Exit(1) @@ -92,7 +106,7 @@ Set PANGOLIN_SSH_BINARY to the full path of ssh(1) to override PATH lookup on al } } - passThrough := mergePassThrough(os.Args, opts.ResourceID, args[1:]) + passThrough := mergePassThrough(os.Args, opts.TargetArgRaw, args[1:]) pt := ParseOpenSSHPassThrough(passThrough) runOpts := RunOpts{ User: signData.User, diff --git a/internal/api/types.go b/internal/api/types.go index 01c21f3..6ae8a8a 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -274,6 +274,7 @@ type ApplyBlueprintResponse struct { type SignSSHKeyRequest struct { PublicKey string `json:"publicKey"` Resource string `json:"resource"` + Username string `json:"username,omitempty"` } type SignSSHKeyData struct {