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
4 changes: 3 additions & 1 deletion .github/workflows/nix-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 57 additions & 0 deletions cmd/resetdns/resetdns_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions cmd/resetdns/resetdns_windows.go
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ 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"
"github.com/fosrl/cli/cmd/status"
"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"
Expand Down Expand Up @@ -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())
Expand Down
12 changes: 11 additions & 1 deletion cmd/scp/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type siteConnectTimedOutMsg struct{}
type connectSpinnerModel struct {
spinner spinner.Model
timedOut bool
canceled bool
}

func newConnectSpinnerModel() connectSpinnerModel {
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
24 changes: 19 additions & 5 deletions cmd/scp/scp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,7 +108,17 @@ 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.
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,
Expand Down
52 changes: 51 additions & 1 deletion cmd/scp/scp_osargs.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
12 changes: 11 additions & 1 deletion cmd/ssh/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type siteConnectTimedOutMsg struct{}
type connectSpinnerModel struct {
spinner spinner.Model
timedOut bool
canceled bool
}

func newConnectSpinnerModel() connectSpinnerModel {
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
9 changes: 5 additions & 4 deletions cmd/ssh/exec_args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 8 additions & 4 deletions cmd/ssh/jit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Loading
Loading