Skip to content

Commit f58533a

Browse files
authored
Merge pull request #61 from fosrl/dev
Dev
2 parents 8a2b53e + 6dbf513 commit f58533a

19 files changed

Lines changed: 707 additions & 263 deletions

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
BINARY_NAME=pangolin
44
OUTPUT_DIR=bin
55

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

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

8383
go-build-release-windows-amd64:
84-
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_windows_amd64.exe
84+
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/pangolin-cli_windows_amd64.exe

cmd/ssh/exec_args.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package ssh
2+
3+
import "strconv"
4+
5+
// buildExecSSHArgs assembles argv for the system ssh(1) binary:
6+
//
7+
// ssh <identity: -l -i -o Certificate -p> <user OpenSSH options> <hostname> <remote command>...
8+
func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPath string, pass SSHPassthrough) []string {
9+
args := []string{sshPath}
10+
if user != "" {
11+
args = append(args, "-l", user)
12+
}
13+
if keyPath != "" {
14+
args = append(args, "-i", keyPath)
15+
}
16+
if certPath != "" {
17+
args = append(args, "-o", "CertificateFile="+certPath)
18+
}
19+
if port > 0 {
20+
args = append(args, "-p", strconv.Itoa(port))
21+
}
22+
args = append(args, pass.Options...)
23+
args = append(args, hostname)
24+
args = append(args, pass.RemoteCommand...)
25+
return args
26+
}

cmd/ssh/exec_ssh_env.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package ssh
2+
3+
import (
4+
"os"
5+
"strings"
6+
)
7+
8+
// envSSHBinary overrides the ssh(1) executable used by RunExec on all platforms when non-empty.
9+
const envSSHBinary = "PANGOLIN_SSH_BINARY"
10+
11+
func sshBinaryFromEnv() (path string, ok bool) {
12+
p := strings.TrimSpace(os.Getenv(envSSHBinary))
13+
if p == "" {
14+
return "", false
15+
}
16+
return p, true
17+
}

cmd/ssh/jit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string) (pr
2828
if err != nil {
2929
return "", "", "", nil, fmt.Errorf("SSH error: %w", err)
3030
}
31-
31+
3232
// Collect all message IDs to poll (support both single and multiple).
3333
var messageIDs []int64
3434
if len(initResp.MessageIDs) > 0 {

cmd/ssh/openssh_passthrough.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package ssh
2+
3+
import "strings"
4+
5+
type SSHPassthrough struct {
6+
Options []string
7+
RemoteCommand []string
8+
}
9+
10+
// ParseOpenSSHPassThrough walks pass-through args (e.g. args[1:] from "pangolin ssh <res> ...").
11+
func ParseOpenSSHPassThrough(args []string) SSHPassthrough {
12+
if len(args) == 0 {
13+
return SSHPassthrough{}
14+
}
15+
var opts []string
16+
i := 0
17+
for i < len(args) {
18+
a := args[i]
19+
if a == "--" {
20+
opts = append(opts, a)
21+
i++
22+
return SSHPassthrough{Options: opts, RemoteCommand: cloneStringSliceOrNil(args[i:])}
23+
}
24+
if !strings.HasPrefix(a, "-") {
25+
break
26+
}
27+
ex := openSSHOptionExtras(a, args, i)
28+
end := i + 1 + ex
29+
if end > len(args) {
30+
end = len(args)
31+
}
32+
opts = append(opts, args[i:end]...)
33+
i = end
34+
}
35+
return SSHPassthrough{Options: opts, RemoteCommand: cloneStringSliceOrNil(args[i:])}
36+
}
37+
38+
func cloneStringSliceOrNil(s []string) []string {
39+
if len(s) == 0 {
40+
return nil
41+
}
42+
return append([]string{}, s...)
43+
}
44+
45+
// openSSHOptionExtras returns how many args after the current token should be part of the same
46+
// option (0 = only the current token, e.g. -N; 1 = one following value, e.g. -F path).
47+
func openSSHOptionExtras(a string, args []string, i int) int {
48+
if a == "" || a == "--" {
49+
return 0
50+
}
51+
// long options
52+
if len(a) > 1 && a[0] == '-' && a[1] == '-' {
53+
if strings.Contains(a, "=") {
54+
return 0
55+
}
56+
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") && longOpenSSHWithArg(a) {
57+
return 1
58+
}
59+
return 0
60+
}
61+
if !strings.HasPrefix(a, "-") || a == "-" {
62+
return 0
63+
}
64+
// exactly two runes: e.g. -N, -L, -1, -2
65+
if len(a) == 2 {
66+
switch a[1] {
67+
case '1', '2', '3', '4', '5', '6', '7', '8', '9',
68+
'N', 'G', 'T', 'C', 'f', 'g', 'n', 'q', 's', 't', 'v', 'x', 'X', 'Y', 'A', 'a', 'M', 'Q', 'V', 'y':
69+
return 0
70+
case 'B', 'b', 'c', 'e', 'E', 'F', 'h', 'I', 'J', 'K', 'L', 'm', 'O', 'o', 'P', 'R', 'S', 'W', 'D', 'i', 'l', 'p', 'U':
71+
return 1
72+
}
73+
return 0
74+
}
75+
// combined short token
76+
if a[0] == '-' {
77+
c := a[1]
78+
switch c {
79+
case 'D':
80+
// -D, -D1080, -D[bind]:port
81+
if len(a) == 2 {
82+
return 1
83+
}
84+
return 0
85+
case 'L', 'R':
86+
// -L, -L8080:host:port
87+
if len(a) == 2 {
88+
return 1
89+
}
90+
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
91+
// e.g. -L with space then spec; if no ':' in a[2:] treat next as spec
92+
if !strings.Contains(a[2:], ":") {
93+
return 1
94+
}
95+
}
96+
return 0
97+
case 'O':
98+
// -O with command (single token) or -O and next
99+
if len(a) == 2 && i+1 < len(args) {
100+
return 1
101+
}
102+
return 0
103+
}
104+
}
105+
return 0
106+
}
107+
108+
func longOpenSSHWithArg(s string) bool {
109+
known := map[string]struct{}{
110+
"--bind-address": {},
111+
"--ciphers": {},
112+
"--kex": {},
113+
"--kexalgorithms": {},
114+
"--log-level": {},
115+
"--macs": {},
116+
"--keygen": {},
117+
"--user": {},
118+
}
119+
_, ok := known[strings.ToLower(s)]
120+
return ok
121+
}

cmd/ssh/runner_exec_unix.go

Lines changed: 9 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
//go:build !windows
12
// +build !windows
23

34
package ssh
45

56
import (
67
"errors"
8+
"fmt"
79
"io"
810
"os"
911
"os/exec"
1012
"os/signal"
1113
"runtime"
12-
"strconv"
1314
"syscall"
1415

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

2728
func findExecSSHPath() (string, error) {
29+
if p, ok := sshBinaryFromEnv(); ok {
30+
if isExecutable(p) {
31+
return p, nil
32+
}
33+
return "", fmt.Errorf("%s=%q: not an executable file", envSSHBinary, p)
34+
}
2835
if path, err := exec.LookPath("ssh"); err == nil {
2936
return path, nil
3037
}
@@ -57,18 +64,6 @@ func execExitCode(err error) int {
5764
return 1
5865
}
5966

60-
// RunOpts is shared by both the exec and native SSH runners.
61-
// PrivateKeyPEM and Certificate are set just-in-time (JIT) before connect; no file paths.
62-
// Port is optional: 0 means use default (22 or whatever is in Hostname); >0 overrides.
63-
type RunOpts struct {
64-
User string
65-
Hostname string
66-
Port int // optional; 0 = default
67-
PrivateKeyPEM string // in-memory private key (PEM, OpenSSH format)
68-
Certificate string // in-memory certificate from sign-key API
69-
PassThrough []string
70-
}
71-
7267
// RunExec runs an interactive SSH session by executing the system ssh binary
7368
// (with a PTY when stdin is a terminal on Unix). Requires ssh to be installed.
7469
// opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert).
@@ -86,7 +81,7 @@ func RunExec(opts RunOpts) (int, error) {
8681
defer cleanup()
8782
}
8883

89-
argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough)
84+
argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.SSHPassthrough)
9085
cmd := exec.Command(argv[0], argv[1:]...)
9186

9287
usePTY := runtime.GOOS != "windows" && isatty.IsTerminal(os.Stdin.Fd())
@@ -152,25 +147,6 @@ func writeExecKeyFiles(opts RunOpts) (keyPath, certPath string, cleanup func(),
152147
return keyPath, certPath, cleanup, nil
153148
}
154149

155-
func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPath string, passThrough []string) []string {
156-
args := []string{sshPath}
157-
if user != "" {
158-
args = append(args, "-l", user)
159-
}
160-
if keyPath != "" {
161-
args = append(args, "-i", keyPath)
162-
}
163-
if certPath != "" {
164-
args = append(args, "-o", "CertificateFile="+certPath)
165-
}
166-
if port > 0 {
167-
args = append(args, "-p", strconv.Itoa(port))
168-
}
169-
args = append(args, hostname)
170-
args = append(args, passThrough...)
171-
return args
172-
}
173-
174150
func runExecWithPTY(cmd *exec.Cmd) (int, error) {
175151
// Put local terminal in raw mode so Ctrl+C and Tab are sent as bytes to the
176152
// remote instead of triggering SIGINT or local completion.

cmd/ssh/runner_exec_windows.go

Lines changed: 13 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ package ssh
55

66
import (
77
"errors"
8+
"fmt"
89
"os"
910
"os/exec"
10-
"strconv"
1111

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

2020
func findExecSSHPathWindows() (string, error) {
21+
if p, ok := sshBinaryFromEnv(); ok {
22+
info, err := os.Stat(p)
23+
if err != nil {
24+
return "", fmt.Errorf("%s=%q: %w", envSSHBinary, p, err)
25+
}
26+
if info.IsDir() {
27+
return "", fmt.Errorf("%s=%q: is a directory", envSSHBinary, p)
28+
}
29+
return p, nil
30+
}
2131
if path, err := exec.LookPath("ssh"); err == nil {
2232
return path, nil
2333
}
@@ -39,20 +49,8 @@ func execExitCode(err error) int {
3949
return 1
4050
}
4151

42-
// RunOpts is shared by both the exec and native SSH runners.
43-
// PrivateKeyPEM and Certificate are set just-in-time (JIT) before connect; no file paths.
44-
// Port is optional: 0 means use default (22 or whatever is in Hostname); >0 overrides.
45-
type RunOpts struct {
46-
User string
47-
Hostname string
48-
Port int // optional; 0 = default
49-
PrivateKeyPEM string // in-memory private key (PEM, OpenSSH format)
50-
Certificate string // in-memory certificate from sign-key API
51-
PassThrough []string
52-
}
53-
5452
// RunExec runs an interactive SSH session by executing the system ssh binary.
55-
// On Windows the system SSH has better support (e.g. terminal, agent). Requires ssh to be installed.
53+
// Requires ssh to be installed (e.g. OpenSSH on Windows in PATH or System32).
5654
// opts.PrivateKeyPEM and opts.Certificate must be set (JIT key + signed cert).
5755
func RunExec(opts RunOpts) (int, error) {
5856
sshPath, err := findExecSSHPathWindows()
@@ -68,7 +66,7 @@ func RunExec(opts RunOpts) (int, error) {
6866
defer cleanup()
6967
}
7068

71-
argv := buildExecSSHArgsWindows(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough)
69+
argv := buildExecSSHArgs(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.SSHPassthrough)
7270
cmd := exec.Command(argv[0], argv[1:]...)
7371
cmd.Stdin = os.Stdin
7472
cmd.Stdout = os.Stdout
@@ -185,22 +183,3 @@ func writeExecKeyFilesWindows(opts RunOpts) (keyPath, certPath string, cleanup f
185183
}
186184
return keyPath, certPath, cleanup, nil
187185
}
188-
189-
func buildExecSSHArgsWindows(sshPath, user, hostname string, port int, keyPath, certPath string, passThrough []string) []string {
190-
args := []string{sshPath}
191-
if user != "" {
192-
args = append(args, "-l", user)
193-
}
194-
if keyPath != "" {
195-
args = append(args, "-i", keyPath)
196-
}
197-
if certPath != "" {
198-
args = append(args, "-o", "CertificateFile="+certPath)
199-
}
200-
if port > 0 {
201-
args = append(args, "-p", strconv.Itoa(port))
202-
}
203-
args = append(args, hostname)
204-
args = append(args, passThrough...)
205-
return args
206-
}

cmd/ssh/runner_opts.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package ssh
2+
3+
type RunOpts struct {
4+
User string
5+
Hostname string
6+
Port int
7+
PrivateKeyPEM string
8+
Certificate string
9+
SSHPassthrough
10+
}

cmd/ssh/sign.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ func SignCmd() *cobra.Command {
9696
}
9797
fmt.Println("Usage with system ssh (scp, tunnels, etc.):")
9898
fmt.Printf(" ssh -i %q -o CertificateFile=%q %s@%s\n", keyPath, certPath, user, hostname)
99+
fmt.Printf(" ssh -i %q -o CertificateFile=%q -L 8080:127.0.0.1:80 -N %s@%s\n", keyPath, certPath, user, hostname)
99100
fmt.Printf(" scp -i %q -o CertificateFile=%q ...\n", keyPath, certPath)
100101
},
101102
}

0 commit comments

Comments
 (0)