Skip to content
Merged
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
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ go-build-release: \
go-build-release-linux-amd64 \
go-build-release-linux-riscv64 \
go-build-release-darwin-arm64 \
go-build-release-darwin-amd64
go-build-release-darwin-amd64 \
go-build-release-windows-amd64

go-build-release-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/pangolin-cli_linux_arm64
Expand All @@ -76,4 +77,7 @@ go-build-release-darwin-arm64:
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/pangolin-cli_darwin_arm64

go-build-release-darwin-amd64:
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/pangolin-cli_darwin_amd64
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/pangolin-cli_darwin_amd64

go-build-release-windows-amd64:
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/pangolin-cli_windows_amd64.exe
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# [Pangolin](https://pangolin.net) CLI

This is the official Pangolin CLI tool and VPN client for Unix devices. Pangolin CLI is currently only available on macOS and Linux. Windows support is coming soon.
This is the official Pangolin CLI tool.

Since there isn't an official GUI application for Linux, this CLI serves as the official way to connect to Pangolin VPN on Linux.

Expand Down
6 changes: 2 additions & 4 deletions cmd/authdaemon/authdaemon.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build linux

package authdaemon

import (
Expand All @@ -6,7 +8,6 @@ import (
"fmt"
"os"
"os/signal"
"runtime"
"syscall"

"github.com/fosrl/cli/internal/logger"
Expand Down Expand Up @@ -38,9 +39,6 @@ func AuthDaemonCmd() *cobra.Command {
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
}
Expand Down
12 changes: 12 additions & 0 deletions cmd/authdaemon/authdaemon_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build darwin

package authdaemon

import (
"github.com/spf13/cobra"
)

// AuthDaemonCmd returns nil on macOS as this command is not supported.
func AuthDaemonCmd() *cobra.Command {
return nil
}
12 changes: 12 additions & 0 deletions cmd/authdaemon/authdaemon_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build windows

package authdaemon

import (
"github.com/spf13/cobra"
)

// AuthDaemonCmd returns nil on Windows as this command is not supported.
func AuthDaemonCmd() *cobra.Command {
return nil
}
4 changes: 3 additions & 1 deletion cmd/down/down.go → cmd/down/down_unix.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !windows

package down

import (
Expand All @@ -20,4 +22,4 @@ If ran with no subcommand, 'client' is passed.
cmd.AddCommand(client.ClientDownCmd())

return cmd
}
}
12 changes: 12 additions & 0 deletions cmd/down/down_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build windows

package down

import (
"github.com/spf13/cobra"
)

// DownCmd returns nil on Windows as this command is not supported.
func DownCmd() *cobra.Command {
return nil
}
4 changes: 3 additions & 1 deletion cmd/logs/logs.go → cmd/logs/logs_unix.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !windows

package logs

import (
Expand All @@ -15,4 +17,4 @@ func LogsCmd() *cobra.Command {
cmd.AddCommand(client.ClientLogsCmd())

return cmd
}
}
12 changes: 12 additions & 0 deletions cmd/logs/logs_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build windows

package logs

import (
"github.com/spf13/cobra"
)

// LogsCmd returns nil on Windows as this command is not supported.
func LogsCmd() *cobra.Command {
return nil
}
23 changes: 18 additions & 5 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,27 @@ func RootCommand(initResources bool) (*cobra.Command, error) {
}

cmd.AddCommand(auth.AuthCommand())
cmd.AddCommand(authdaemon.AuthDaemonCmd())
if authDaemonCmd := authdaemon.AuthDaemonCmd(); authDaemonCmd != nil {
cmd.AddCommand(authDaemonCmd)
}
cmd.AddCommand(apply.ApplyCommand())
cmd.AddCommand(selectcmd.SelectCmd())
cmd.AddCommand(up.UpCmd())
cmd.AddCommand(down.DownCmd())
cmd.AddCommand(logs.LogsCmd())

// Platform-specific commands - nil on unsupported platforms
if upCmd := up.UpCmd(); upCmd != nil {
cmd.AddCommand(upCmd)
}
if downCmd := down.DownCmd(); downCmd != nil {
cmd.AddCommand(downCmd)
}
if logsCmd := logs.LogsCmd(); logsCmd != nil {
cmd.AddCommand(logsCmd)
}
if statusCmd := status.StatusCmd(); statusCmd != nil {
cmd.AddCommand(statusCmd)
}

cmd.AddCommand(ssh.SSHCmd())
cmd.AddCommand(status.StatusCmd())
cmd.AddCommand(update.UpdateCmd())
cmd.AddCommand(version.VersionCmd())
cmd.AddCommand(login.LoginCmd())
Expand Down
2 changes: 2 additions & 0 deletions cmd/ssh/runner_exec.go → cmd/ssh/runner_exec_unix.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// +build !windows

package ssh

import (
Expand Down
150 changes: 150 additions & 0 deletions cmd/ssh/runner_exec_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//go:build windows
// +build windows

package ssh

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

// execSSHSearchPaths are fallback locations for the ssh executable on Windows.
var execSSHSearchPaths = []string{
`C:\Windows\System32\OpenSSH\ssh.exe`,
}

func findExecSSHPathWindows() (string, error) {
if path, err := exec.LookPath("ssh"); err == nil {
return path, nil
}
for _, p := range execSSHSearchPaths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", errors.New("ssh executable not found in PATH or in OpenSSH location (C:\\Windows\\System32\\OpenSSH\\ssh.exe)")
}

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.
// On Windows the system SSH has better support (e.g. terminal, agent). 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 := findExecSSHPathWindows()
if err != nil {
return 1, err
}

keyPath, certPath, cleanup, err := writeExecKeyFilesWindows(opts)
if err != nil {
return 1, err
}
if cleanup != nil {
defer cleanup()
}

argv := buildExecSSHArgsWindows(sshPath, opts.User, opts.Hostname, opts.Port, keyPath, certPath, opts.PassThrough)
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 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.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 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
}
19 changes: 3 additions & 16 deletions cmd/ssh/runner_native.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@ import (
"fmt"
"net"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"syscall"

"github.com/mattn/go-isatty"
"golang.org/x/crypto/ssh"
Expand Down Expand Up @@ -44,7 +41,7 @@ func RunNative(opts RunOpts) (int, error) {
defer session.Close()

stdinFd := int(os.Stdin.Fd())
useRaw := isatty.IsTerminal(uintptr(stdinFd)) && runtime.GOOS != "windows"
useRaw := isatty.IsTerminal(uintptr(stdinFd))
if useRaw {
oldState, err := term.MakeRaw(stdinFd)
if err != nil {
Expand All @@ -69,19 +66,9 @@ func RunNative(opts RunOpts) (int, error) {
return 1, fmt.Errorf("request pty: %w", err)
}

// Resize on SIGWINCH (Unix, TTY only)
// Setup terminal window resize handling (platform-specific)
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
setupWindowChangeHandler(session, stdinFd)
}

session.Stdin = os.Stdin
Expand Down
29 changes: 29 additions & 0 deletions cmd/ssh/runner_native_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build !windows

package ssh

import (
"os"
"os/signal"
"syscall"

"golang.org/x/crypto/ssh"
"golang.org/x/term"
)

// setupWindowChangeHandler sets up terminal window size change handling for Unix systems.
// It listens for SIGWINCH signals and updates the SSH session's terminal size accordingly.
func setupWindowChangeHandler(session *ssh.Session, stdinFd int) {
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)
// Trigger initial window size update
winchCh <- syscall.SIGWINCH
}
Loading
Loading