Skip to content
Merged

Dev #61

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
BINARY_NAME=pangolin
OUTPUT_DIR=bin

VERSION ?= dev
VERSION ?= version_replaceme
LDFLAGS = -s -w -X github.com/fosrl/cli/internal/version.Version=$(VERSION)

all: clean build
Expand Down Expand Up @@ -81,4 +81,4 @@ go-build-release-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_darwin_amd64

go-build-release-windows-amd64:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_windows_amd64.exe
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_windows_amd64.exe
26 changes: 26 additions & 0 deletions cmd/ssh/exec_args.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ssh

import "strconv"

// buildExecSSHArgs assembles argv for the system ssh(1) binary:
//
// ssh <identity: -l -i -o Certificate -p> <user OpenSSH options> <hostname> <remote command>...
func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPath string, pass SSHPassthrough) []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, pass.Options...)
args = append(args, hostname)
args = append(args, pass.RemoteCommand...)
return args
}
17 changes: 17 additions & 0 deletions cmd/ssh/exec_ssh_env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package ssh

import (
"os"
"strings"
)

// envSSHBinary overrides the ssh(1) executable used by RunExec on all platforms when non-empty.
const envSSHBinary = "PANGOLIN_SSH_BINARY"

func sshBinaryFromEnv() (path string, ok bool) {
p := strings.TrimSpace(os.Getenv(envSSHBinary))
if p == "" {
return "", false
}
return p, true
}
2 changes: 1 addition & 1 deletion cmd/ssh/jit.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr
if err != nil {
return "", "", "", nil, fmt.Errorf("SSH error: %w", err)
}

// Collect all message IDs to poll (support both single and multiple).
var messageIDs []int64
if len(initResp.MessageIDs) > 0 {
Expand Down
121 changes: 121 additions & 0 deletions cmd/ssh/openssh_passthrough.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package ssh

import "strings"

type SSHPassthrough struct {
Options []string
RemoteCommand []string
}

// ParseOpenSSHPassThrough walks pass-through args (e.g. args[1:] from "pangolin ssh <res> ...").
func ParseOpenSSHPassThrough(args []string) SSHPassthrough {
if len(args) == 0 {
return SSHPassthrough{}
}
var opts []string
i := 0
for i < len(args) {
a := args[i]
if a == "--" {
opts = append(opts, a)
i++
return SSHPassthrough{Options: opts, RemoteCommand: cloneStringSliceOrNil(args[i:])}
}
if !strings.HasPrefix(a, "-") {
break
}
ex := openSSHOptionExtras(a, args, i)
end := i + 1 + ex
if end > len(args) {
end = len(args)
}
opts = append(opts, args[i:end]...)
i = end
}
return SSHPassthrough{Options: opts, RemoteCommand: cloneStringSliceOrNil(args[i:])}
}

func cloneStringSliceOrNil(s []string) []string {
if len(s) == 0 {
return nil
}
return append([]string{}, s...)
}

// openSSHOptionExtras returns how many args after the current token should be part of the same
// option (0 = only the current token, e.g. -N; 1 = one following value, e.g. -F path).
func openSSHOptionExtras(a string, args []string, i int) int {
if a == "" || a == "--" {
return 0
}
// long options
if len(a) > 1 && a[0] == '-' && a[1] == '-' {
if strings.Contains(a, "=") {
return 0
}
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") && longOpenSSHWithArg(a) {
return 1
}
return 0
}
if !strings.HasPrefix(a, "-") || a == "-" {
return 0
}
// exactly two runes: e.g. -N, -L, -1, -2
if len(a) == 2 {
switch a[1] {
case '1', '2', '3', '4', '5', '6', '7', '8', '9',
'N', 'G', 'T', 'C', 'f', 'g', 'n', 'q', 's', 't', 'v', 'x', 'X', 'Y', 'A', 'a', 'M', 'Q', 'V', 'y':
return 0
case 'B', 'b', 'c', 'e', 'E', 'F', 'h', 'I', 'J', 'K', 'L', 'm', 'O', 'o', 'P', 'R', 'S', 'W', 'D', 'i', 'l', 'p', 'U':
return 1
}
return 0
}
// combined short token
if a[0] == '-' {
c := a[1]
switch c {
case 'D':
// -D, -D1080, -D[bind]:port
if len(a) == 2 {
return 1
}
return 0
case 'L', 'R':
// -L, -L8080:host:port
if len(a) == 2 {
return 1
}
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
// e.g. -L with space then spec; if no ':' in a[2:] treat next as spec
if !strings.Contains(a[2:], ":") {
return 1
}
}
return 0
case 'O':
// -O with command (single token) or -O and next
if len(a) == 2 && i+1 < len(args) {
return 1
}
return 0
}
}
return 0
}

func longOpenSSHWithArg(s string) bool {
known := map[string]struct{}{
"--bind-address": {},
"--ciphers": {},
"--kex": {},
"--kexalgorithms": {},
"--log-level": {},
"--macs": {},
"--keygen": {},
"--user": {},
}
_, ok := known[strings.ToLower(s)]
return ok
}
42 changes: 9 additions & 33 deletions cmd/ssh/runner_exec_unix.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
//go:build !windows
// +build !windows

package ssh

import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"runtime"
"strconv"
"syscall"

"github.com/creack/pty"
Expand All @@ -25,6 +26,12 @@ var execSSHSearchPaths = []string{
}

func findExecSSHPath() (string, error) {
if p, ok := sshBinaryFromEnv(); ok {
if isExecutable(p) {
return p, nil
}
return "", fmt.Errorf("%s=%q: not an executable file", envSSHBinary, p)
}
if path, err := exec.LookPath("ssh"); err == nil {
return path, nil
}
Expand Down Expand Up @@ -57,18 +64,6 @@ func execExitCode(err error) int {
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).
Expand All @@ -86,7 +81,7 @@ func RunExec(opts RunOpts) (int, error) {
defer cleanup()
}

argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough)
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())
Expand Down Expand Up @@ -152,25 +147,6 @@ func writeExecKeyFiles(opts RunOpts) (keyPath, certPath string, cleanup func(),
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.
Expand Down
47 changes: 13 additions & 34 deletions cmd/ssh/runner_exec_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ package ssh

import (
"errors"
"fmt"
"os"
"os/exec"
"strconv"

"golang.org/x/sys/windows"
)
Expand All @@ -18,6 +18,16 @@ var execSSHSearchPaths = []string{
}

func findExecSSHPathWindows() (string, error) {
if p, ok := sshBinaryFromEnv(); ok {
info, err := os.Stat(p)
if err != nil {
return "", fmt.Errorf("%s=%q: %w", envSSHBinary, p, err)
}
if info.IsDir() {
return "", fmt.Errorf("%s=%q: is a directory", envSSHBinary, p)
}
return p, nil
}
if path, err := exec.LookPath("ssh"); err == nil {
return path, nil
}
Expand All @@ -39,20 +49,8 @@ func execExitCode(err error) int {
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.
// On Windows the system SSH has better support (e.g. terminal, agent). Requires ssh to be installed.
// Requires ssh to be installed (e.g. OpenSSH on Windows in PATH or System32).
// opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert).
func RunExec(opts RunOpts) (int, error) {
sshPath, err := findExecSSHPathWindows()
Expand All @@ -68,7 +66,7 @@ func RunExec(opts RunOpts) (int, error) {
defer cleanup()
}

argv := buildExecSSHArgsWindows(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough)
argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.SSHPassthrough)
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
Expand Down Expand Up @@ -185,22 +183,3 @@ func writeExecKeyFilesWindows(opts RunOpts) (keyPath, certPath string, cleanup f
}
return keyPath, certPath, cleanup, nil
}

func buildExecSSHArgsWindows(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
}
10 changes: 10 additions & 0 deletions cmd/ssh/runner_opts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package ssh

type RunOpts struct {
User string
Hostname string
Port int
PrivateKeyPEM string
Certificate string
SSHPassthrough
}
1 change: 1 addition & 0 deletions cmd/ssh/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func SignCmd() *cobra.Command {
}
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(" ssh -i %q -o CertificateFile=%q -L 8080:127.0.0.1:80 -N %s@%s\n", keyPath, certPath, user, hostname)
fmt.Printf(" scp -i %q -o CertificateFile=%q ...\n", keyPath, certPath)
},
}
Expand Down
Loading
Loading