Skip to content

Commit 485ffd7

Browse files
Merge pull request #86 from fosrl/dev
0.10.0
2 parents 26ed4be + 9bccb68 commit 485ffd7

19 files changed

Lines changed: 499 additions & 27 deletions

File tree

.github/workflows/nix-build.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ jobs:
1919
uses: DeterminateSystems/nix-installer-action@main
2020

2121
- name: Build flake package
22+
env:
23+
NIXPKGS_ALLOW_UNFREE: "1"
2224
run: |
23-
nix build .#pangolin-cli -L
25+
nix build .#pangolin-cli -L --impure

cmd/resetdns/resetdns_unix.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//go:build !windows
2+
3+
package resetdns
4+
5+
import (
6+
"errors"
7+
"os"
8+
9+
"github.com/fosrl/cli/internal/logger"
10+
"github.com/fosrl/cli/internal/olm"
11+
dnsOverride "github.com/fosrl/olm/dns/override"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// ResetDNSCmd returns the `pangolin reset-dns` command which forcibly
16+
// removes any stale DNS override left behind by a crashed client.
17+
func ResetDNSCmd() *cobra.Command {
18+
var interfaceName string
19+
var force bool
20+
21+
cmd := &cobra.Command{
22+
Use: "reset-dns",
23+
Short: "Force-clear stale DNS overrides",
24+
Long: `Forcibly clear stale DNS overrides left behind by a crashed or
25+
stuck client. This restores your system DNS to its original
26+
configuration.
27+
28+
By default this command refuses to run when a client is still
29+
active; use --force to override that check.`,
30+
RunE: func(cmd *cobra.Command, args []string) error {
31+
client := olm.NewClient("")
32+
if client.IsRunning() && !force {
33+
return errors.New("a client is currently running; stop it first with 'pangolin down' or rerun with --force")
34+
}
35+
if client.IsRunning() && force {
36+
logger.Warning("Client appears to still be running; attempting reset anyway because --force was passed")
37+
}
38+
39+
if os.Geteuid() != 0 {
40+
logger.Warning("DNS reset typically requires root privileges; rerun with sudo if it fails")
41+
}
42+
43+
if err := dnsOverride.ForceResetDNS(interfaceName); err != nil {
44+
logger.Error("DNS reset failed: %v", err)
45+
return err
46+
}
47+
48+
logger.Success("DNS configuration reset")
49+
return nil
50+
},
51+
}
52+
53+
cmd.Flags().StringVar(&interfaceName, "interface", "pangolin", "Tunnel interface name to clean up")
54+
cmd.Flags().BoolVar(&force, "force", false, "Run the reset even if a client appears to be active")
55+
56+
return cmd
57+
}

cmd/resetdns/resetdns_windows.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build windows
2+
3+
package resetdns
4+
5+
import "github.com/spf13/cobra"
6+
7+
// ResetDNSCmd is unsupported on Windows where DNS overrides are
8+
// interface-GUID scoped and reclaimed automatically when the WireGuard
9+
// interface is torn down.
10+
func ResetDNSCmd() *cobra.Command {
11+
return nil
12+
}

cmd/root.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import (
1414
"github.com/fosrl/cli/cmd/down"
1515
"github.com/fosrl/cli/cmd/list"
1616
"github.com/fosrl/cli/cmd/logs"
17+
"github.com/fosrl/cli/cmd/resetdns"
1718
"github.com/fosrl/cli/cmd/scp"
1819
selectcmd "github.com/fosrl/cli/cmd/select"
1920
"github.com/fosrl/cli/cmd/ssh"
2021
"github.com/fosrl/cli/cmd/status"
2122
"github.com/fosrl/cli/cmd/up"
2223
"github.com/fosrl/cli/cmd/update"
2324
"github.com/fosrl/cli/cmd/version"
25+
"github.com/fosrl/cli/cmd/watchdog"
2426
"github.com/fosrl/cli/internal/api"
2527
"github.com/fosrl/cli/internal/config"
2628
"github.com/fosrl/cli/internal/logger"
@@ -66,6 +68,12 @@ func RootCommand(initResources bool) (*cobra.Command, error) {
6668
if statusCmd := status.StatusCmd(); statusCmd != nil {
6769
cmd.AddCommand(statusCmd)
6870
}
71+
if resetDNSCmd := resetdns.ResetDNSCmd(); resetDNSCmd != nil {
72+
cmd.AddCommand(resetDNSCmd)
73+
}
74+
if watchdogCmd := watchdog.WatchdogCmd(); watchdogCmd != nil {
75+
cmd.AddCommand(watchdogCmd)
76+
}
6977

7078
cmd.AddCommand(ssh.SSHCmd())
7179
cmd.AddCommand(scp.SCPCmd())

cmd/scp/connect.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type siteConnectTimedOutMsg struct{}
2727
type connectSpinnerModel struct {
2828
spinner spinner.Model
2929
timedOut bool
30+
canceled bool
3031
}
3132

3233
func newConnectSpinnerModel() connectSpinnerModel {
@@ -41,12 +42,17 @@ func (m connectSpinnerModel) Init() tea.Cmd {
4142
}
4243

4344
func (m connectSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44-
switch msg.(type) {
45+
switch msg := msg.(type) {
4546
case siteConnectedMsg:
4647
return m, tea.Quit
4748
case siteConnectTimedOutMsg:
4849
m.timedOut = true
4950
return m, tea.Quit
51+
case tea.KeyMsg:
52+
if msg.Type == tea.KeyCtrlC {
53+
m.canceled = true
54+
return m, tea.Quit
55+
}
5056
}
5157
var cmd tea.Cmd
5258
m.spinner, cmd = m.spinner.Update(msg)
@@ -119,5 +125,9 @@ func waitForAnySiteConnection(client *olm.Client, siteIDs []int) error {
119125
return fmt.Errorf("Timed out waiting for site to connect. Please disconnect (down) then reconnect (up) the client and try again.")
120126
}
121127

128+
if finalModel.(connectSpinnerModel).canceled {
129+
return fmt.Errorf("connection canceled")
130+
}
131+
122132
return nil
123133
}

cmd/scp/scp.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,15 @@ Examples:
4444
4545
Set PANGOLIN_SCP_BINARY to the full path of scp(1) to override PATH lookup on all platforms.`,
4646
PreRunE: func(c *cobra.Command, args []string) error {
47-
if len(args) < 2 {
48-
return errScpOperands
49-
}
50-
username, resourceID, found := parseSCPRemoteHost(args)
47+
// Use os.Args directly so that unknown boolean scp flags (e.g. -r,
48+
// -p, -v) do not cause pflag to swallow the following operand as a
49+
// flag value.
50+
rawArgs := rawSCPArgs()
51+
username, resourceID, found := parseSCPRemoteHost(rawArgs)
5152
if !found {
53+
if countSCPOperands(rawArgs) < 2 {
54+
return errScpOperands
55+
}
5256
return errNoRemoteOperand
5357
}
5458
opts.Username = username
@@ -104,7 +108,17 @@ Set PANGOLIN_SCP_BINARY to the full path of scp(1) to override PATH lookup on al
104108
}
105109
}
106110

107-
pt := sshcmd.ParseOpenSSHPassThrough(args)
111+
pt := sshcmd.ParseOpenSSHPassThrough(rawSCPArgs())
112+
113+
// When the auth daemon is the native SSH server, restrict
114+
// pass-through options to the subset it actually supports.
115+
if signData.AuthDaemonMode == "native" {
116+
var stripped []string
117+
pt, stripped = sshcmd.FilterForNativeSCPMode(pt)
118+
if len(stripped) > 0 {
119+
logger.Warning("The following options are not supported by the native SSH server and were ignored: %s", sshcmd.NativeStrippedWarning(stripped))
120+
}
121+
}
108122

109123
runOpts := RunOpts{
110124
User: signData.User,

cmd/scp/scp_osargs.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,56 @@
11
package scp
22

3-
import "strings"
3+
import (
4+
"os"
5+
"strings"
6+
)
7+
8+
// rawSCPArgs returns the arguments passed to the scp subcommand directly from
9+
// os.Args, bypassing Cobra/pflag flag parsing. This is necessary because pflag
10+
// does not know whether unknown short flags (e.g. -r, -p, -v) take an argument,
11+
// so it may incorrectly consume the following operand as the flag's value.
12+
func rawSCPArgs() []string {
13+
for i, a := range os.Args {
14+
if a == "scp" {
15+
return os.Args[i+1:]
16+
}
17+
}
18+
return nil
19+
}
20+
21+
// countSCPOperands returns the number of non-flag positional operands in args.
22+
func countSCPOperands(args []string) int {
23+
count := 0
24+
i := 0
25+
for i < len(args) {
26+
a := args[i]
27+
if a == "--" {
28+
count += len(args[i+1:])
29+
break
30+
}
31+
if strings.HasPrefix(a, "-") && a != "-" {
32+
i += 1 + scpFlagExtras(a, args, i)
33+
continue
34+
}
35+
count++
36+
i++
37+
}
38+
return count
39+
}
40+
41+
// scpFlagExtras returns how many additional args the given scp short flag consumes.
42+
func scpFlagExtras(a string, args []string, i int) int {
43+
if len(a) == 2 {
44+
switch a[1] {
45+
// scp flags that take a value
46+
case 'F', 'i', 'J', 'l', 'o', 'P', 'S':
47+
if i+1 < len(args) {
48+
return 1
49+
}
50+
}
51+
}
52+
return 0
53+
}
454

555
// parseSCPRemoteHost scans scp operands and returns the username and resource ID
656
// from the first remote operand (host:path or user@host:path). Local paths are skipped.

cmd/ssh/connect.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type siteConnectTimedOutMsg struct{}
2727
type connectSpinnerModel struct {
2828
spinner spinner.Model
2929
timedOut bool
30+
canceled bool
3031
}
3132

3233
func newConnectSpinnerModel() connectSpinnerModel {
@@ -41,12 +42,17 @@ func (m connectSpinnerModel) Init() tea.Cmd {
4142
}
4243

4344
func (m connectSpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
44-
switch msg.(type) {
45+
switch msg := msg.(type) {
4546
case siteConnectedMsg:
4647
return m, tea.Quit
4748
case siteConnectTimedOutMsg:
4849
m.timedOut = true
4950
return m, tea.Quit
51+
case tea.KeyMsg:
52+
if msg.Type == tea.KeyCtrlC {
53+
m.canceled = true
54+
return m, tea.Quit
55+
}
5056
}
5157
var cmd tea.Cmd
5258
m.spinner, cmd = m.spinner.Update(msg)
@@ -128,5 +134,9 @@ func waitForAnySiteConnection(client *olm.Client, siteIDs []int) error {
128134
return fmt.Errorf("Timed out waiting for site to connect. Please disconnect (down) then reconnect (up) the client and try again.")
129135
}
130136

137+
if finalModel.(connectSpinnerModel).canceled {
138+
return fmt.Errorf("connection canceled")
139+
}
140+
131141
return nil
132142
}

cmd/ssh/exec_args.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ func buildExecSSHArgs(sshPath, user, hostname string, port int, keyPath, certPat
1616
if certPath != "" {
1717
args = append(args, "-o", "CertificateFile="+certPath)
1818
}
19-
// JIT cert-based auth should not fall back to interactive password prompts.
19+
// Prefer JIT cert/publickey auth first, but allow interactive fallback
20+
// (password/keyboard-interactive) when the server supports it.
2021
args = append(args,
2122
"-o", "PubkeyAuthentication=yes",
22-
"-o", "PreferredAuthentications=publickey",
23+
"-o", "PreferredAuthentications=publickey,keyboard-interactive,password",
2324
"-o", "IdentitiesOnly=yes",
24-
"-o", "PasswordAuthentication=no",
25-
"-o", "KbdInteractiveAuthentication=no",
25+
"-o", "PasswordAuthentication=yes",
26+
"-o", "KbdInteractiveAuthentication=yes",
2627
)
2728
// The built-in SSH server generates a fresh ephemeral host key on every
2829
// restart, so skip known_hosts checking to avoid spurious MITM warnings.

cmd/ssh/jit.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string, use
6868
} else if initResp.MessageID != 0 {
6969
messageIDs = []int64{initResp.MessageID}
7070
} else {
71-
if err := validateSignedCert(pubKey, initResp.Certificate); err != nil {
72-
return "", "", "", nil, fmt.Errorf("SSH error: invalid certificate: %w", err)
71+
if initResp.AuthDaemonMode != "native" {
72+
if err := validateSignedCert(pubKey, initResp.Certificate); err != nil {
73+
return "", "", "", nil, fmt.Errorf("SSH error: invalid certificate: %w", err)
74+
}
7375
}
7476
// return the data as this is okay
7577
return privPEM, pubKey, initResp.Certificate, initResp, nil
@@ -88,8 +90,10 @@ func GenerateAndSignKey(client *api.Client, orgID string, resourceID string, use
8890
if msg.Error != nil && *msg.Error != "" {
8991
return "", "", "", nil, fmt.Errorf("SSH error: %s", *msg.Error)
9092
}
91-
if err := validateSignedCert(pubKey, initResp.Certificate); err != nil {
92-
return "", "", "", nil, fmt.Errorf("SSH error: invalid certificate: %w", err)
93+
if initResp.AuthDaemonMode != "native" {
94+
if err := validateSignedCert(pubKey, initResp.Certificate); err != nil {
95+
return "", "", "", nil, fmt.Errorf("SSH error: invalid certificate: %w", err)
96+
}
9397
}
9498
return privPEM, pubKey, initResp.Certificate, initResp, nil
9599
}

0 commit comments

Comments
 (0)