From d7c0e14cd53dc27809fa80f52d80c793ccfffb02 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 4 Jun 2026 14:55:09 -0700 Subject: [PATCH 1/8] Allow ctrl-c to exit the connecting spinner --- cmd/scp/connect.go | 12 +++++++++++- cmd/ssh/connect.go | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/cmd/scp/connect.go b/cmd/scp/connect.go index 85b4cae..b9d9068 100644 --- a/cmd/scp/connect.go +++ b/cmd/scp/connect.go @@ -27,6 +27,7 @@ type siteConnectTimedOutMsg struct{} type connectSpinnerModel struct { spinner spinner.Model timedOut bool + canceled bool } func newConnectSpinnerModel() connectSpinnerModel { @@ -41,12 +42,17 @@ func (m connectSpinnerModel) Init() tea.Cmd { } func (m connectSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg.(type) { + switch msg := msg.(type) { case siteConnectedMsg: return m, tea.Quit case siteConnectTimedOutMsg: m.timedOut = true return m, tea.Quit + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.canceled = true + return m, tea.Quit + } } var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) @@ -119,5 +125,9 @@ func waitForAnySiteConnection(client *olm.Client, siteIDs []int) error { return fmt.Errorf("Timed out waiting for site to connect. Please disconnect (down) then reconnect (up) the client and try again.") } + if finalModel.(connectSpinnerModel).canceled { + return fmt.Errorf("connection canceled") + } + return nil } diff --git a/cmd/ssh/connect.go b/cmd/ssh/connect.go index b1ecccd..28d0b2c 100644 --- a/cmd/ssh/connect.go +++ b/cmd/ssh/connect.go @@ -27,6 +27,7 @@ type siteConnectTimedOutMsg struct{} type connectSpinnerModel struct { spinner spinner.Model timedOut bool + canceled bool } func newConnectSpinnerModel() connectSpinnerModel { @@ -41,12 +42,17 @@ func (m connectSpinnerModel) Init() tea.Cmd { } func (m connectSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg.(type) { + switch msg := msg.(type) { case siteConnectedMsg: return m, tea.Quit case siteConnectTimedOutMsg: m.timedOut = true return m, tea.Quit + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.canceled = true + return m, tea.Quit + } } var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) @@ -128,5 +134,9 @@ func waitForAnySiteConnection(client *olm.Client, siteIDs []int) error { return fmt.Errorf("Timed out waiting for site to connect. Please disconnect (down) then reconnect (up) the client and try again.") } + if finalModel.(connectSpinnerModel).canceled { + return fmt.Errorf("connection canceled") + } + return nil } From 3a2014585b551fb283d08b01b311fd3423089cc3 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 5 Jun 2026 13:56:14 -0700 Subject: [PATCH 2/8] Filter the options for the ssh command if native --- cmd/scp/scp.go | 10 ++ cmd/ssh/native_filter.go | 209 +++++++++++++++++++++++++++++++++++++++ cmd/ssh/ssh.go | 12 +++ internal/api/types.go | 11 ++- 4 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 cmd/ssh/native_filter.go diff --git a/cmd/scp/scp.go b/cmd/scp/scp.go index 4679a95..a062726 100644 --- a/cmd/scp/scp.go +++ b/cmd/scp/scp.go @@ -106,6 +106,16 @@ Set PANGOLIN_SCP_BINARY to the full path of scp(1) to override PATH lookup on al pt := sshcmd.ParseOpenSSHPassThrough(args) + // When the auth daemon is the native SSH server, restrict + // pass-through options to the subset it actually supports. + if signData.AuthDaemonMode == "native" { + var stripped []string + pt, stripped = sshcmd.FilterForNativeSCPMode(pt) + if len(stripped) > 0 { + logger.Warning("The following options are not supported by the native SSH server and were ignored: %s", sshcmd.NativeStrippedWarning(stripped)) + } + } + runOpts := RunOpts{ User: signData.User, Hostname: signData.Hostname, diff --git a/cmd/ssh/native_filter.go b/cmd/ssh/native_filter.go new file mode 100644 index 0000000..0f8a3c4 --- /dev/null +++ b/cmd/ssh/native_filter.go @@ -0,0 +1,209 @@ +package ssh + +import "strings" + +// FilterForNativeMode strips SSH options that the native SSH server does not +// support and returns the safe subset along with the list of rejected tokens. +// +// The native server (newt/nativessh) handles interactive PTY sessions and +// exec requests (remote commands, scp, rsync). It does not support: +// - Port forwarding (-L, -R, -D) +// - Tunnel mode (-W) +// - Agent forwarding (-A, -a) +// - X11 forwarding (-X, -Y) +// - ControlMaster (-M, -S, -O) +// - Gateway ports (-g) +// - Background mode (-f) +// - No-shell mode (-N) +// - Jump host (-J) +// - Subsystem mode (-s) +// - Arbitrary -o opts (may enable unsupported features or conflict with +// the auth flags already injected by buildExecSSHArgs) +// +// Allowed: client-side presentation flags that do not require server-side +// handling: -v/-vv/-vvv (verbosity), -t (force PTY), -T (no PTY), -q (quiet), +// -C (compression), -e (escape character). Remote commands are passed +// through unchanged because the native server supports exec requests. +func FilterForNativeMode(pt SSHPassthrough) (SSHPassthrough, []string) { + var allowed []string + var stripped []string + + opts := pt.Options + i := 0 + for i < len(opts) { + tok := opts[i] + if tok == "--" { + // Anything after "--" is a remote command delimiter; skip it and + // treat remaining tokens as part of the remote command block. + i++ + break + } + + ok, consumesNext := nativeAllowedOption(tok) + if ok { + allowed = append(allowed, tok) + i++ + if consumesNext && i < len(opts) { + allowed = append(allowed, opts[i]) + i++ + } + } else { + stripped = append(stripped, tok) + // Consume the value token that belongs to this flag, if any. + extras := openSSHOptionExtras(tok, opts, i) + for j := 0; j < extras && i+1 < len(opts); j++ { + i++ + stripped = append(stripped, opts[i]) + } + i++ + } + } + + // Remote commands pass through unchanged — the native server supports exec. + var out SSHPassthrough + if len(allowed) > 0 { + out.Options = allowed + } + out.RemoteCommand = pt.RemoteCommand + return out, stripped +} + +// FilterForNativeSCPMode strips scp(1) options that are unsafe or meaningless +// against the native SSH server and returns the safe subset and rejected tokens. +// +// Allowed scp flags: -r (recursive), -p (preserve times), -q (quiet), +// -v/-vv/… (verbosity), -C (compression), -B (batch mode), -3 (via local), +// -l (bandwidth), -c . +// Blocked: -o (arbitrary SSH options), -J (jump host), and anything unknown. +func FilterForNativeSCPMode(pt SSHPassthrough) (SSHPassthrough, []string) { + var allowed []string + var stripped []string + + opts := pt.Options + i := 0 + for i < len(opts) { + tok := opts[i] + if tok == "--" { + i++ + break + } + + ok, consumesNext := scpNativeAllowedOption(tok) + if ok { + allowed = append(allowed, tok) + i++ + if consumesNext && i < len(opts) { + allowed = append(allowed, opts[i]) + i++ + } + } else { + stripped = append(stripped, tok) + extras := openSSHOptionExtras(tok, opts, i) + for j := 0; j < extras && i+1 < len(opts); j++ { + i++ + stripped = append(stripped, opts[i]) + } + i++ + } + } + + var out SSHPassthrough + if len(allowed) > 0 { + out.Options = allowed + } + // SCP operands (source/dest) are in RemoteCommand — always pass through. + out.RemoteCommand = pt.RemoteCommand + return out, stripped +} + +// scpNativeAllowedOption reports whether a scp(1) flag is safe for the native server. +func scpNativeAllowedOption(tok string) (allowed bool, consumesNext bool) { + if tok == "" || tok == "--" || !strings.HasPrefix(tok, "-") || tok == "-" { + return false, false + } + + // -v, -vv, -vvv, … — verbosity is client-side only. + if len(tok) >= 2 { + allV := true + for _, c := range tok[1:] { + if c != 'v' { + allV = false + break + } + } + if allV { + return true, false + } + } + + switch tok { + case "-r", "-R": // recursive + return true, false + case "-p": // preserve modification times and modes + return true, false + case "-q": // quiet + return true, false + case "-C": // compression + return true, false + case "-B": // batch mode (no password prompts) + return true, false + case "-3": // copy via local host + return true, false + case "-l": // bandwidth limit — consumes next token + return true, true + case "-c": // cipher specification — consumes next token + return true, true + } + + return false, false +} + +// nativeAllowedOption reports whether an ssh(1) flag is safe for the native server. +func nativeAllowedOption(tok string) (allowed bool, consumesNext bool) { + if tok == "" || tok == "--" || !strings.HasPrefix(tok, "-") || tok == "-" { + return false, false + } + + // -v, -vv, -vvv, … — verbosity is client-side only. + if len(tok) >= 2 { + allV := true + for _, c := range tok[1:] { + if c != 'v' { + allV = false + break + } + } + if allV { + return true, false + } + } + + switch tok { + case "-t": // force PTY allocation + return true, false + case "-T": // disable PTY allocation + return true, false + case "-q": // quiet mode + return true, false + case "-C": // compression (negotiated at transport layer) + return true, false + case "-e": // escape character — value is consumed + return true, true + } + + return false, false +} + +// NativeStrippedWarning builds a concise warning string from the list of +// rejected tokens, deduplicating flag names (but not their values). +func NativeStrippedWarning(stripped []string) string { + seen := make(map[string]struct{}, len(stripped)) + unique := make([]string, 0, len(stripped)) + for _, s := range stripped { + if _, dup := seen[s]; !dup { + seen[s] = struct{}{} + unique = append(unique, s) + } + } + return strings.Join(unique, " ") +} diff --git a/cmd/ssh/ssh.go b/cmd/ssh/ssh.go index 85fd342..e5701fa 100644 --- a/cmd/ssh/ssh.go +++ b/cmd/ssh/ssh.go @@ -108,6 +108,18 @@ Set PANGOLIN_SSH_BINARY to the full path of ssh(1) to override PATH lookup on al passThrough := mergePassThrough(os.Args, opts.TargetArgRaw, args[1:]) pt := ParseOpenSSHPassThrough(passThrough) + + // When the auth daemon is the native SSH server, restrict + // pass-through options to the subset it actually supports. + // An undefined or non-"native" mode imposes no restriction. + if signData.AuthDaemonMode == "native" { + var stripped []string + pt, stripped = FilterForNativeMode(pt) + if len(stripped) > 0 { + logger.Warning("The following options are not supported by the native SSH server and were ignored: %s", NativeStrippedWarning(stripped)) + } + } + runOpts := RunOpts{ User: signData.User, Hostname: signData.Hostname, diff --git a/internal/api/types.go b/internal/api/types.go index 6ae8a8a..9319f60 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -286,11 +286,12 @@ type SignSSHKeyData struct { 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) - ResourceID int `json:"resourceId"` // resource ID for SSH connection (returned by API) - SiteIDs []int `json:"siteIds"` // site ID for SSH connection (returned by API) - SiteID int `json:"siteId"` // site ID for SSH connection (returned by API) + AuthDaemonMode string `json:"authDaemonMode"` // "internal" or "agent" + Hostname string `json:"sshHost"` // hostname for SSH connection (returned by API) + User string `json:"sshUsername"` // user for SSH connection (returned by API) + ResourceID int `json:"resourceId"` // resource ID for SSH connection (returned by API) + SiteIDs []int `json:"siteIds"` // site ID for SSH connection (returned by API) + SiteID int `json:"siteId"` // site ID for SSH connection (returned by API) } type RoundTripMessage struct { From 6c4b62642bd27af101baa93807f2a6c2899a0dc9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 8 Jun 2026 21:26:15 -0700 Subject: [PATCH 3/8] Adjust exec args --- cmd/ssh/exec_args.go | 9 +++++---- cmd/ssh/jit.go | 12 ++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/cmd/ssh/exec_args.go b/cmd/ssh/exec_args.go index bcfa6eb..abee4ac 100644 --- a/cmd/ssh/exec_args.go +++ b/cmd/ssh/exec_args.go @@ -16,13 +16,14 @@ 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. + // Prefer JIT cert/publickey auth first, but allow interactive fallback + // (password/keyboard-interactive) when the server supports it. args = append(args, "-o", "PubkeyAuthentication=yes", - "-o", "PreferredAuthentications=publickey", + "-o", "PreferredAuthentications=publickey,keyboard-interactive,password", "-o", "IdentitiesOnly=yes", - "-o", "PasswordAuthentication=no", - "-o", "KbdInteractiveAuthentication=no", + "-o", "PasswordAuthentication=yes", + "-o", "KbdInteractiveAuthentication=yes", ) // The built-in SSH server generates a fresh ephemeral host key on every // restart, so skip known_hosts checking to avoid spurious MITM warnings. diff --git a/cmd/ssh/jit.go b/cmd/ssh/jit.go index 41b6be0..52b2157 100644 --- a/cmd/ssh/jit.go +++ b/cmd/ssh/jit.go @@ -68,8 +68,10 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string, use } else if initResp.MessageID != 0 { messageIDs = []int64{initResp.MessageID} } else { - if err := validateSignedCert(pubKey, initResp.Certificate); err != nil { - return "", "", "", nil, fmt.Errorf("SSH error: invalid certificate: %w", err) + if initResp.AuthDaemonMode != "native" { + 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 @@ -88,8 +90,10 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string, use 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) + if initResp.AuthDaemonMode != "native" { + 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 } From bc401991edff1a3ebbb59055eb4e899172d0351e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 9 Jun 2026 20:51:15 -0700 Subject: [PATCH 4/8] Add watchdog command and reset command --- cmd/resetdns/resetdns_unix.go | 57 +++++++++++++++++++++++++++ cmd/resetdns/resetdns_windows.go | 12 ++++++ cmd/root.go | 8 ++++ cmd/up/client/client.go | 5 +++ cmd/watchdog/watchdog_unix.go | 67 ++++++++++++++++++++++++++++++++ cmd/watchdog/watchdog_windows.go | 10 +++++ 6 files changed, 159 insertions(+) create mode 100644 cmd/resetdns/resetdns_unix.go create mode 100644 cmd/resetdns/resetdns_windows.go create mode 100644 cmd/watchdog/watchdog_unix.go create mode 100644 cmd/watchdog/watchdog_windows.go diff --git a/cmd/resetdns/resetdns_unix.go b/cmd/resetdns/resetdns_unix.go new file mode 100644 index 0000000..74313a3 --- /dev/null +++ b/cmd/resetdns/resetdns_unix.go @@ -0,0 +1,57 @@ +//go:build !windows + +package resetdns + +import ( + "errors" + "os" + + "github.com/fosrl/cli/internal/logger" + "github.com/fosrl/cli/internal/olm" + dnsOverride "github.com/fosrl/olm/dns/override" + "github.com/spf13/cobra" +) + +// ResetDNSCmd returns the `pangolin reset-dns` command which forcibly +// removes any stale DNS override left behind by a crashed client. +func ResetDNSCmd() *cobra.Command { + var interfaceName string + var force bool + + cmd := &cobra.Command{ + Use: "reset-dns", + Short: "Force-clear stale DNS overrides", + Long: `Forcibly clear stale DNS overrides left behind by a crashed or +stuck client. This restores your system DNS to its original +configuration. + +By default this command refuses to run when a client is still +active; use --force to override that check.`, + RunE: func(cmd *cobra.Command, args []string) error { + client := olm.NewClient("") + if client.IsRunning() && !force { + return errors.New("a client is currently running; stop it first with 'pangolin down' or rerun with --force") + } + if client.IsRunning() && force { + logger.Warning("Client appears to still be running; attempting reset anyway because --force was passed") + } + + if os.Geteuid() != 0 { + logger.Warning("DNS reset typically requires root privileges; rerun with sudo if it fails") + } + + if err := dnsOverride.ForceResetDNS(interfaceName); err != nil { + logger.Error("DNS reset failed: %v", err) + return err + } + + logger.Success("DNS configuration reset") + return nil + }, + } + + cmd.Flags().StringVar(&interfaceName, "interface", "pangolin", "Tunnel interface name to clean up") + cmd.Flags().BoolVar(&force, "force", false, "Run the reset even if a client appears to be active") + + return cmd +} diff --git a/cmd/resetdns/resetdns_windows.go b/cmd/resetdns/resetdns_windows.go new file mode 100644 index 0000000..d372112 --- /dev/null +++ b/cmd/resetdns/resetdns_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package resetdns + +import "github.com/spf13/cobra" + +// ResetDNSCmd is unsupported on Windows where DNS overrides are +// interface-GUID scoped and reclaimed automatically when the WireGuard +// interface is torn down. +func ResetDNSCmd() *cobra.Command { + return nil +} diff --git a/cmd/root.go b/cmd/root.go index add06f3..47d72a5 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/resetdns" "github.com/fosrl/cli/cmd/scp" selectcmd "github.com/fosrl/cli/cmd/select" "github.com/fosrl/cli/cmd/ssh" @@ -21,6 +22,7 @@ import ( "github.com/fosrl/cli/cmd/up" "github.com/fosrl/cli/cmd/update" "github.com/fosrl/cli/cmd/version" + "github.com/fosrl/cli/cmd/watchdog" "github.com/fosrl/cli/internal/api" "github.com/fosrl/cli/internal/config" "github.com/fosrl/cli/internal/logger" @@ -66,6 +68,12 @@ func RootCommand(initResources bool) (*cobra.Command, error) { if statusCmd := status.StatusCmd(); statusCmd != nil { cmd.AddCommand(statusCmd) } + if resetDNSCmd := resetdns.ResetDNSCmd(); resetDNSCmd != nil { + cmd.AddCommand(resetDNSCmd) + } + if watchdogCmd := watchdog.WatchdogCmd(); watchdogCmd != nil { + cmd.AddCommand(watchdogCmd) + } cmd.AddCommand(ssh.SSHCmd()) cmd.AddCommand(scp.SCPCmd()) diff --git a/cmd/up/client/client.go b/cmd/up/client/client.go index f497abb..28935dc 100644 --- a/cmd/up/client/client.go +++ b/cmd/up/client/client.go @@ -540,6 +540,11 @@ func clientUpMain(cmd *cobra.Command, opts *ClientUpCmdOpts, extraArgs []string) HTTPAddr: opts.HTTPAddr, Version: versionpkg.Version, Agent: defaultAgent, + // Spawn the pangolin binary itself in watchdog mode after a DNS + // override is installed. The watchdog will reset DNS if this + // process dies before it can restore the original configuration. + WatchdogSubcommand: []string{"watchdog"}, + WatchdogLogFile: cfg.LogFile, OnTerminated: func() { logger.Info("Client process terminated") stop() diff --git a/cmd/watchdog/watchdog_unix.go b/cmd/watchdog/watchdog_unix.go new file mode 100644 index 0000000..2696103 --- /dev/null +++ b/cmd/watchdog/watchdog_unix.go @@ -0,0 +1,67 @@ +//go:build !windows + +package watchdog + +import ( + "context" + "errors" + "os/signal" + "syscall" + "time" + + "github.com/fosrl/cli/internal/logger" + dnsOverride "github.com/fosrl/olm/dns/override" + "github.com/spf13/cobra" +) + +// WatchdogCmd returns the hidden `pangolin watchdog` command. It is +// spawned by the long-running client process so that DNS overrides are +// reset if the client dies before restoring them. End users do not need +// to invoke this directly. +func WatchdogCmd() *cobra.Command { + var ( + parentPID int + socketPath string + interfaceName string + interval time.Duration + threshold int + ) + + cmd := &cobra.Command{ + Use: "watchdog", + Hidden: true, + Short: "Internal DNS override watchdog", + Long: `Internal command spawned by the client to monitor a running +olm process and forcibly reset DNS if it dies. End users do +not need to invoke this directly.`, + RunE: func(cmd *cobra.Command, args []string) error { + if parentPID <= 0 { + return errors.New("--parent-pid is required") + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + err := dnsOverride.RunWatchdog(ctx, dnsOverride.WatchdogConfig{ + ParentPID: parentPID, + SocketPath: socketPath, + InterfaceName: interfaceName, + CheckInterval: interval, + FailureThreshold: threshold, + }) + if err != nil && !errors.Is(err, context.Canceled) { + logger.Error("Watchdog exited with error: %v", err) + return err + } + return nil + }, + } + + cmd.Flags().IntVar(&parentPID, "parent-pid", 0, "PID of the client process to monitor") + cmd.Flags().StringVar(&socketPath, "socket", "", "Path to the client unix socket (optional)") + cmd.Flags().StringVar(&interfaceName, "interface", "pangolin", "Tunnel interface name to clean up") + cmd.Flags().DurationVar(&interval, "interval", 5*time.Second, "Liveness check interval") + cmd.Flags().IntVar(&threshold, "threshold", 3, "Consecutive failures before DNS reset") + + return cmd +} diff --git a/cmd/watchdog/watchdog_windows.go b/cmd/watchdog/watchdog_windows.go new file mode 100644 index 0000000..a2c46fe --- /dev/null +++ b/cmd/watchdog/watchdog_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package watchdog + +import "github.com/spf13/cobra" + +// WatchdogCmd is unsupported on Windows. +func WatchdogCmd() *cobra.Command { + return nil +} From bd88e4f554f24cbf9ad3beec53729cbbb0c48735 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Jun 2026 10:41:20 -0700 Subject: [PATCH 5/8] Fix scp -r flag --- cmd/scp/scp.go | 14 +++++++----- cmd/scp/scp_osargs.go | 52 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/cmd/scp/scp.go b/cmd/scp/scp.go index a062726..d8d0d7b 100644 --- a/cmd/scp/scp.go +++ b/cmd/scp/scp.go @@ -44,11 +44,15 @@ Examples: 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) + // Use os.Args directly so that unknown boolean scp flags (e.g. -r, + // -p, -v) do not cause pflag to swallow the following operand as a + // flag value. + rawArgs := rawSCPArgs() + username, resourceID, found := parseSCPRemoteHost(rawArgs) if !found { + if countSCPOperands(rawArgs) < 2 { + return errScpOperands + } return errNoRemoteOperand } opts.Username = username @@ -104,7 +108,7 @@ Set PANGOLIN_SCP_BINARY to the full path of scp(1) to override PATH lookup on al } } - pt := sshcmd.ParseOpenSSHPassThrough(args) + pt := sshcmd.ParseOpenSSHPassThrough(rawSCPArgs()) // When the auth daemon is the native SSH server, restrict // pass-through options to the subset it actually supports. diff --git a/cmd/scp/scp_osargs.go b/cmd/scp/scp_osargs.go index 247e88a..3f71327 100644 --- a/cmd/scp/scp_osargs.go +++ b/cmd/scp/scp_osargs.go @@ -1,6 +1,56 @@ package scp -import "strings" +import ( + "os" + "strings" +) + +// rawSCPArgs returns the arguments passed to the scp subcommand directly from +// os.Args, bypassing Cobra/pflag flag parsing. This is necessary because pflag +// does not know whether unknown short flags (e.g. -r, -p, -v) take an argument, +// so it may incorrectly consume the following operand as the flag's value. +func rawSCPArgs() []string { + for i, a := range os.Args { + if a == "scp" { + return os.Args[i+1:] + } + } + return nil +} + +// countSCPOperands returns the number of non-flag positional operands in args. +func countSCPOperands(args []string) int { + count := 0 + i := 0 + for i < len(args) { + a := args[i] + if a == "--" { + count += len(args[i+1:]) + break + } + if strings.HasPrefix(a, "-") && a != "-" { + i += 1 + scpFlagExtras(a, args, i) + continue + } + count++ + i++ + } + return count +} + +// scpFlagExtras returns how many additional args the given scp short flag consumes. +func scpFlagExtras(a string, args []string, i int) int { + if len(a) == 2 { + switch a[1] { + // scp flags that take a value + case 'F', 'i', 'J', 'l', 'o', 'P', 'S': + if i+1 < len(args) { + return 1 + } + } + } + return 0 +} // 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. From 62677e9da65921d2cccc67a67a1902cf0a329d9d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Jun 2026 11:21:44 -0700 Subject: [PATCH 6/8] Bump olm --- go.mod | 4 ++-- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 025df40..47b7043 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,13 @@ go 1.25.0 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/Microsoft/go-winio v0.6.2 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/fosrl/newt v1.12.2 - github.com/fosrl/olm v1.5.2 + github.com/fosrl/olm v1.6.0 github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 @@ -25,7 +26,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect diff --git a/go.sum b/go.sum index 80dc696..0500a79 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fosrl/newt v1.12.2 h1:k99iF+twyggRG+PZTXPYxDyqwM969FPsPfGhtlcttPI= github.com/fosrl/newt v1.12.2/go.mod h1:IJW2sZ4WKKLRuxMz6oBm8PMyAEVkOxZk6d1OUV5/LPM= -github.com/fosrl/olm v1.5.2 h1:wraqCGa4ryo2NbnF4Yb4xqkjwUyRQCiCNHORRE7Eakc= -github.com/fosrl/olm v1.5.2/go.mod h1:SiDJhJIvG4rH4JofNSDN2dRRqWu7NIvtXykVSmOR54w= +github.com/fosrl/olm v1.6.0 h1:twoUg2rXCCXHU0IKcR4n4T87NYVatS8E7DaKRT1+MaU= +github.com/fosrl/olm v1.6.0/go.mod h1:SiDJhJIvG4rH4JofNSDN2dRRqWu7NIvtXykVSmOR54w= 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= From 756b73cbeb8f45a3634d65cac714d8ba9a4d61d3 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Jun 2026 11:33:10 -0700 Subject: [PATCH 7/8] Update nix --- .github/workflows/nix-build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nix-build.yml b/.github/workflows/nix-build.yml index 157334c..8421d73 100644 --- a/.github/workflows/nix-build.yml +++ b/.github/workflows/nix-build.yml @@ -19,5 +19,7 @@ jobs: uses: DeterminateSystems/nix-installer-action@main - name: Build flake package + env: + NIXPKGS_ALLOW_UNFREE: "1" run: | - nix build .#pangolin-cli -L + nix build .#pangolin-cli -L --impure From 9bccb68febfe0372584ad965cc97b6ee7269975e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 10 Jun 2026 11:34:41 -0700 Subject: [PATCH 8/8] Update flake --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 479ae6f..07c2563 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ version = "0.1.0"; src = ./.; - vendorHash = "sha256-6rWNo84a+aqcHgjtNqrgfYnERSO6AdWwZ36+mhxk6Z8="; + vendorHash = "sha256-UmzzZDO2lz/HsrUlnV8Wa4GM8lYgoI0ggJlOvxrd79Q="; ldflags = [ "-s"