feat: sftp, scp, and named port forwards
Add netssh sftp and netssh scp. Both resolve the host like a connection does (cache, shortcuts and rewrites, instance/host, default user and port, SSH options and rules, --jump/--no-jump, --dry-run) and get shell completion. The per-client flag tables handle the differences from ssh: the port is -P, -l is a bandwidth limit (the default user goes in as -o User=), and IPv6 addresses are bracketed in host:path. All remote scp operands must name the same host. Add named port forwards under ssh.forwards (global, per instance, per rule; a more specific layer replaces a forward of the same name), netssh tunnel HOST NAME|SPEC... (ssh -N with ExitOnForwardFailure) and netssh --forward NAME. Specs such as 8080:localhost:80 work without configuration. Forwards are editable in netssh configure and completed in the shell; the TUI opens SFTP with ctrl+o and a tunnel with ctrl+t. Host resolution moves out of runSSHWrapper into resolveTarget, shared by ssh, sftp, scp, tunnel, and the TUI. Options added by netssh are now inserted before the first operand or "--": after "--", ssh read them as the destination, and scp stops reading options at the first operand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
878701aba7
commit
b7fa71fe45
19 files changed
+1389
-141
No files matched your search
@@ -22,6 +22,48 @@ type SSHOptions struct {
|
||||
ExtraArgs []string `mapstructure:"extra_args"`
|
||||
// Options are passed as -o Key=Value.
|
||||
Options map[string]string `mapstructure:"options"`
|
||||
// Forwards are named port forwards, selected with --forward or netssh tunnel.
|
||||
Forwards map[string]Forward `mapstructure:"forwards"`
|
||||
}
|
||||
|
||||
// Forward is one port forward; exactly one of the fields is set.
|
||||
type Forward struct {
|
||||
Local string `mapstructure:"local"` // -L [bind:]port:host:hostport
|
||||
Remote string `mapstructure:"remote"` // -R [bind:]port:host:hostport
|
||||
Dynamic string `mapstructure:"dynamic"` // -D [bind:]port (SOCKS proxy)
|
||||
}
|
||||
|
||||
// Flag returns the ssh arguments for the forward, e.g. -L 8080:localhost:80.
|
||||
func (f Forward) Flag() []string {
|
||||
switch {
|
||||
case f.Local != "":
|
||||
return []string{"-L", f.Local}
|
||||
case f.Remote != "":
|
||||
return []string{"-R", f.Remote}
|
||||
case f.Dynamic != "":
|
||||
return []string{"-D", f.Dynamic}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// String renders the forward as its ssh flag, e.g. "-L 8080:localhost:80".
|
||||
func (f Forward) String() string { return strings.Join(f.Flag(), " ") }
|
||||
|
||||
func (f Forward) validate() error {
|
||||
set := 0
|
||||
for _, spec := range []string{f.Local, f.Remote, f.Dynamic} {
|
||||
if spec == "" {
|
||||
continue
|
||||
}
|
||||
set++
|
||||
if strings.HasPrefix(spec, "-") || strings.ContainsAny(spec, " \t\n\r") {
|
||||
return fmt.Errorf("invalid forward %q", spec)
|
||||
}
|
||||
}
|
||||
if set != 1 {
|
||||
return fmt.Errorf("set exactly one of local, remote, dynamic")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSHRule applies SSH options to hosts that match every non-empty field.
|
||||
@@ -113,6 +155,14 @@ func (o SSHOptions) Validate(scope string) error {
|
||||
return fmt.Errorf("%s.options: invalid option %q", scope, key)
|
||||
}
|
||||
}
|
||||
for name, forward := range o.Forwards {
|
||||
if !instanceName.MatchString(name) {
|
||||
return fmt.Errorf("%s.forwards: name %q must contain only letters, digits, underscores, or hyphens", scope, name)
|
||||
}
|
||||
if err := forward.validate(); err != nil {
|
||||
return fmt.Errorf("%s.forwards.%s: %v", scope, name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,6 +197,14 @@ func (o SSHOptions) merge(override SSHOptions) SSHOptions {
|
||||
for key, value := range override.Options {
|
||||
out.Options[strings.ToLower(key)] = value
|
||||
}
|
||||
// Forwards override per name, so a rule can redefine a global "web" forward.
|
||||
out.Forwards = make(map[string]Forward, len(o.Forwards)+len(override.Forwards))
|
||||
for name, forward := range o.Forwards {
|
||||
out.Forwards[strings.ToLower(name)] = forward
|
||||
}
|
||||
for name, forward := range override.Forwards {
|
||||
out.Forwards[strings.ToLower(name)] = forward
|
||||
}
|
||||
if override.ProxyJump != "" {
|
||||
out.ProxyJump = override.ProxyJump
|
||||
}
|
||||
|
||||
@@ -88,3 +88,50 @@ func TestSSHOptionsValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardsLayering(t *testing.T) {
|
||||
cfg, err := loadYAML(t, `
|
||||
netboxes:
|
||||
- name: prod
|
||||
url: https://prod.example
|
||||
token: t
|
||||
ssh:
|
||||
forwards:
|
||||
db: {local: "5432:localhost:5432"}
|
||||
ssh:
|
||||
forwards:
|
||||
web: {local: "8080:localhost:80"}
|
||||
socks: {dynamic: "1080"}
|
||||
rules:
|
||||
- match: {tag: dmz}
|
||||
forwards:
|
||||
web: {local: "8443:localhost:443"}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := cfg.SSHOptionsFor(SSHHost{Instance: "prod", Name: "web01", Tags: []string{"dmz"}}).Forwards
|
||||
want := map[string]Forward{"web": {Local: "8443:localhost:443"}, "socks": {Dynamic: "1080"}, "db": {Local: "5432:localhost:5432"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if got := cfg.SSHOptionsFor(SSHHost{Instance: "other", Name: "web01"}).Forwards; len(got) != 2 || got["web"].Local != "8080:localhost:80" {
|
||||
t.Fatalf("other instance: %+v", got)
|
||||
}
|
||||
if f := (Forward{Remote: "9000:localhost:9000"}); f.String() != "-R 9000:localhost:9000" {
|
||||
t.Fatal(f.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardsValidation(t *testing.T) {
|
||||
for _, forwards := range []string{
|
||||
`web: {local: "8080:a:80", dynamic: "1080"}`,
|
||||
`web: {local: "-oProxyCommand=x"}`,
|
||||
`web: {local: "8080 a:80"}`,
|
||||
`"bad name": {local: "8080:a:80"}`,
|
||||
} {
|
||||
if _, err := loadYAML(t, "ssh:\n forwards:\n "+forwards+"\n"); err == nil || !strings.Contains(err.Error(), "forwards") {
|
||||
t.Errorf("%s: got %v", forwards, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error
|
||||
addressFamily := o.AddressFamily
|
||||
extraArgs := strings.Join(o.ExtraArgs, "\n")
|
||||
options := formatOptions(o.Options)
|
||||
forwards := formatForwards(o.Forwards)
|
||||
|
||||
err := huh.NewForm(huh.NewGroup(
|
||||
huh.NewInput().
|
||||
@@ -58,13 +59,25 @@ func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error
|
||||
_, err := parseOptions(s)
|
||||
return err
|
||||
}),
|
||||
huh.NewText().
|
||||
Title("Port forwards").
|
||||
Description("One per line: NAME L|R|D SPEC, e.g. web L 8080:localhost:80 or socks D 1080.\n"+
|
||||
"Use with netssh tunnel HOST NAME or netssh --forward NAME HOST.").
|
||||
Lines(3).
|
||||
Value(&forwards).
|
||||
Validate(func(s string) error {
|
||||
_, err := parseForwards(s)
|
||||
return err
|
||||
}),
|
||||
).Title(title)).Run()
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
|
||||
parsed, _ := parseOptions(options)
|
||||
parsedForwards, _ := parseForwards(forwards)
|
||||
return config.SSHOptions{
|
||||
Forwards: parsedForwards,
|
||||
ProxyJump: strings.TrimSpace(proxyJump),
|
||||
HostKeyChecking: hostKeyChecking,
|
||||
Target: target,
|
||||
@@ -105,6 +118,48 @@ func parseOptions(s string) (map[string]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// forwardKinds maps the letters used in the form to the ssh flags.
|
||||
var forwardKinds = map[string]func(spec string) config.Forward{
|
||||
"L": func(spec string) config.Forward { return config.Forward{Local: spec} },
|
||||
"R": func(spec string) config.Forward { return config.Forward{Remote: spec} },
|
||||
"D": func(spec string) config.Forward { return config.Forward{Dynamic: spec} },
|
||||
}
|
||||
|
||||
// parseForwards reads "NAME L|R|D SPEC" lines into ssh.forwards.
|
||||
func parseForwards(s string) (map[string]config.Forward, error) {
|
||||
var out map[string]config.Forward
|
||||
for _, line := range parseLines(s) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) != 3 || forwardKinds[strings.ToUpper(fields[1])] == nil {
|
||||
return nil, fmt.Errorf("%q: expected NAME L|R|D SPEC", line)
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string]config.Forward)
|
||||
}
|
||||
out[strings.ToLower(fields[0])] = forwardKinds[strings.ToUpper(fields[1])](fields[2])
|
||||
}
|
||||
if err := (config.SSHOptions{Forwards: out}).Validate("ssh"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func formatForwards(forwards map[string]config.Forward) string {
|
||||
names := make([]string, 0, len(forwards))
|
||||
for name := range forwards {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
lines := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
flag := forwards[name].Flag()
|
||||
if len(flag) == 2 {
|
||||
lines = append(lines, name+" "+strings.TrimPrefix(flag[0], "-")+" "+flag[1])
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func formatOptions(options map[string]string) string {
|
||||
keys := make([]string, 0, len(options))
|
||||
for key := range options {
|
||||
|
||||
@@ -70,3 +70,23 @@ func TestRuleValidation(t *testing.T) {
|
||||
t.Fatalf("valid match rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseForwardsRoundtrip(t *testing.T) {
|
||||
text := "Web L 8080:localhost:80\n\nsocks d 1080\nback R 9000:localhost:9000\n"
|
||||
got, err := parseForwards(text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if formatForwards(got) != "back R 9000:localhost:9000\nsocks D 1080\nweb L 8080:localhost:80" {
|
||||
t.Fatalf("format: %q", formatForwards(got))
|
||||
}
|
||||
for _, bad := range []string{"web 8080:localhost:80", "web X 8080", "web L -oFoo", "bad/name L 1:a:2"} {
|
||||
if _, err := parseForwards(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func runMenu(cfg *config.Config) error {
|
||||
Description("Choose a section to edit, then save.").
|
||||
Options(
|
||||
huh.NewOption("SSH defaults — user, port", "defaults"),
|
||||
huh.NewOption("SSH options — jump host, host keys, target, -o", "ssh"),
|
||||
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
|
||||
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
|
||||
huh.NewOption("Resolver & cache", "resolver"),
|
||||
huh.NewOption("Shortcuts & command alias", "shortcuts"),
|
||||
@@ -452,7 +452,7 @@ func save(cfg config.Config) error {
|
||||
}
|
||||
|
||||
func sshOptionsEmpty(o config.SSHOptions) bool {
|
||||
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0
|
||||
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0 && len(o.Forwards) == 0
|
||||
}
|
||||
|
||||
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
|
||||
@@ -479,4 +479,20 @@ func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
|
||||
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
|
||||
}
|
||||
}
|
||||
if len(o.Forwards) > 0 {
|
||||
names := make([]string, 0, len(o.Forwards))
|
||||
for name := range o.Forwards {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Fprintf(b, "%sforwards:\n", indent)
|
||||
for _, name := range names {
|
||||
f := o.Forwards[name]
|
||||
for _, field := range [][2]string{{"local", f.Local}, {"remote", f.Remote}, {"dynamic", f.Dynamic}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(b, "%s %s:\n%s %s: %q\n", indent, name, indent, field[0], field[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,3 +542,29 @@ func TestSaveShortcutRewritesRoundtrip(t *testing.T) {
|
||||
t.Fatalf("got %+v", loaded.Shortcuts.Rewrites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveForwardsRoundtrip(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
global := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}}
|
||||
cfg := config.Config{
|
||||
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", SSH: config.SSHOptions{Forwards: map[string]config.Forward{"db": {Local: "5432:localhost:5432"}}}}},
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||
SSH: config.SSHConfig{
|
||||
SSHOptions: config.SSHOptions{Forwards: global},
|
||||
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz"}, SSHOptions: config.SSHOptions{Forwards: map[string]config.Forward{"back": {Remote: "9000:localhost:9000"}}}}},
|
||||
},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.SSH.Forwards, global) ||
|
||||
!reflect.DeepEqual(loaded.NetBoxes[0].SSH.Forwards, cfg.NetBoxes[0].SSH.Forwards) ||
|
||||
!reflect.DeepEqual(loaded.SSH.Rules[0].Forwards, cfg.SSH.Rules[0].Forwards) {
|
||||
t.Fatalf("forwards lost: %+v / %+v / %+v", loaded.SSH.Forwards, loaded.NetBoxes[0].SSH.Forwards, loaded.SSH.Rules)
|
||||
}
|
||||
}
|
||||
+56
-33
@@ -12,15 +12,19 @@ var flagsWithArg = map[byte]bool{
|
||||
|
||||
// ParsedArgs holds the result of parsing SSH arguments.
|
||||
type ParsedArgs struct {
|
||||
Host string // hostname without the user@ prefix
|
||||
Host string // hostname without the user@ prefix or :path
|
||||
User string // empty if not specified
|
||||
Path string // sftp only: the remote path after host:
|
||||
DestIdx int // index in Args where [user@]host sits
|
||||
Args []string
|
||||
}
|
||||
|
||||
// Parse scans SSH arguments and extracts the destination ([user@]host).
|
||||
// Returns nil if no destination is found.
|
||||
func Parse(args []string) *ParsedArgs {
|
||||
func Parse(args []string) *ParsedArgs { return SSH.Parse(args) }
|
||||
|
||||
// Parse extracts the destination: [user@]host, or [user@]host[:path] for sftp.
|
||||
func (c Client) Parse(args []string) *ParsedArgs {
|
||||
i := 0
|
||||
for i < len(args) {
|
||||
arg := args[i]
|
||||
@@ -33,64 +37,83 @@ func Parse(args []string) *ParsedArgs {
|
||||
|
||||
if strings.HasPrefix(arg, "-") && len(arg) > 1 {
|
||||
// OpenSSH accepts clusters such as -vp 2222 and -vladmin.
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if flagsWithArg[arg[j]] {
|
||||
if j == len(arg)-1 {
|
||||
i++
|
||||
}
|
||||
break
|
||||
}
|
||||
if c.takesSeparateValue(arg) {
|
||||
i++
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// first non-flag argument is the destination
|
||||
host, user := splitUserHost(arg)
|
||||
return &ParsedArgs{
|
||||
Host: host,
|
||||
User: user,
|
||||
DestIdx: i,
|
||||
Args: args,
|
||||
}
|
||||
return c.parsed(args, i)
|
||||
}
|
||||
|
||||
// handle arguments after "--"
|
||||
if i < len(args) {
|
||||
host, user := splitUserHost(args[i])
|
||||
return &ParsedArgs{
|
||||
Host: host,
|
||||
User: user,
|
||||
DestIdx: i,
|
||||
Args: args,
|
||||
}
|
||||
return c.parsed(args, i)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Client) parsed(args []string, i int) *ParsedArgs {
|
||||
dest, path := args[i], ""
|
||||
if c.paths {
|
||||
if d, p, remote := splitRemote(dest); remote {
|
||||
dest, path = d, p
|
||||
}
|
||||
}
|
||||
host, user := splitUserHost(dest)
|
||||
return &ParsedArgs{Host: host, User: user, Path: path, DestIdx: i, Args: args}
|
||||
}
|
||||
|
||||
// ReplaceHost returns a copy of args with the destination replaced by newHost,
|
||||
// preserving any user@ prefix.
|
||||
func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
||||
return SSH.replaceHost(args, []int{destIdx}, newHost)
|
||||
}
|
||||
|
||||
// replaceHost keeps user@ and, for sftp and scp, :path. Those clients read
|
||||
// host:path, so an IPv6 address must be bracketed.
|
||||
func (c Client) replaceHost(args []string, dests []int, newHost string) []string {
|
||||
result := make([]string, len(args))
|
||||
copy(result, args)
|
||||
|
||||
original := args[destIdx]
|
||||
if at := strings.Index(original, "@"); at != -1 {
|
||||
result[destIdx] = original[:at+1] + newHost
|
||||
} else {
|
||||
result[destIdx] = newHost
|
||||
if c.paths && strings.Contains(newHost, ":") {
|
||||
newHost = "[" + newHost + "]"
|
||||
}
|
||||
for _, destIdx := range dests {
|
||||
original := args[destIdx]
|
||||
prefix, suffix := "", ""
|
||||
if at := strings.Index(original, "@"); at != -1 {
|
||||
prefix, original = original[:at+1], original[at+1:]
|
||||
}
|
||||
if c.paths {
|
||||
if _, path, remote := splitRemote(original); remote {
|
||||
suffix = ":" + path
|
||||
}
|
||||
}
|
||||
result[destIdx] = prefix + newHost + suffix
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HasPortFlag reports whether a port was explicitly supplied.
|
||||
func HasPortFlag(args []string) bool { return hasOption(args, 'p', "Port") }
|
||||
func HasPortFlag(args []string) bool { return SSH.HasPortFlag(args) }
|
||||
|
||||
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||
func HasUserFlag(args []string) bool { return hasOption(args, 'l', "User") }
|
||||
func HasUserFlag(args []string) bool { return SSH.HasUserFlag(args) }
|
||||
|
||||
// HasPortFlag reports whether a port was explicitly supplied.
|
||||
func (c Client) HasPortFlag(args []string) bool { return c.hasOption(args, c.port, "Port") }
|
||||
|
||||
// HasUserFlag reports whether a login user was explicitly supplied.
|
||||
func (c Client) HasUserFlag(args []string) bool { return c.hasOption(args, c.user, "User") }
|
||||
|
||||
func hasOption(args []string, wanted byte, keyword string) bool {
|
||||
return SSH.hasOption(args, wanted, keyword)
|
||||
}
|
||||
|
||||
func (c Client) hasOption(args []string, wanted byte, keyword string) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||
@@ -98,7 +121,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
|
||||
}
|
||||
for j := 1; j < len(arg); j++ {
|
||||
flag := arg[j]
|
||||
if !flagsWithArg[flag] {
|
||||
if !c.withArg[flag] {
|
||||
continue
|
||||
}
|
||||
value := arg[j+1:]
|
||||
@@ -109,7 +132,7 @@ func hasOption(args []string, wanted byte, keyword string) bool {
|
||||
}
|
||||
value = args[i]
|
||||
}
|
||||
if flag == wanted {
|
||||
if wanted != 0 && flag == wanted {
|
||||
return true
|
||||
}
|
||||
if flag == 'o' {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Client describes one OpenSSH program netssh can run. The programs share
|
||||
// ssh's connection options but differ in flags and destination syntax.
|
||||
type Client struct {
|
||||
Name string // binary name: ssh, sftp, or scp
|
||||
withArg map[byte]bool // short flags that consume a value
|
||||
port byte // flag for the port: -p (ssh) or -P (sftp, scp)
|
||||
user byte // flag for the login user; 0 when only user@ or -o User work
|
||||
paths bool // destinations are host[:path]
|
||||
}
|
||||
|
||||
var (
|
||||
// SSH is the default client; its flag table is flagsWithArg.
|
||||
SSH = Client{Name: "ssh", withArg: flagsWithArg, port: 'p', user: 'l'}
|
||||
// SFTP follows sftp(1): -l is a bandwidth limit, -P the port.
|
||||
SFTP = Client{Name: "sftp", withArg: flagSet("BbcDFiJloPRSsX"), port: 'P', paths: true}
|
||||
// SCP follows scp(1); unlike sftp, -B and -R take no value.
|
||||
SCP = Client{Name: "scp", withArg: flagSet("cDFiJloPSX"), port: 'P', paths: true}
|
||||
)
|
||||
|
||||
func flagSet(flags string) map[byte]bool {
|
||||
set := make(map[byte]bool, len(flags))
|
||||
for i := 0; i < len(flags); i++ {
|
||||
set[flags[i]] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// PortArgs returns the arguments that set port for this client.
|
||||
func (c Client) PortArgs(port string) []string { return []string{"-" + string(c.port), port} }
|
||||
|
||||
// UserArgs returns the arguments that set the login user for this client.
|
||||
func (c Client) UserArgs(user string) []string {
|
||||
if c.user == 0 {
|
||||
return []string{"-o", "User=" + user}
|
||||
}
|
||||
return []string{"-" + string(c.user), user}
|
||||
}
|
||||
|
||||
// Operand is a remote scp argument such as user@host:/path.
|
||||
type Operand struct {
|
||||
Idx int
|
||||
User string
|
||||
Host string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Operands returns the remote operands of scp arguments. All remote operands
|
||||
// must name the same host, because one set of options applies per call.
|
||||
func (c Client) Operands(args []string) ([]Operand, error) {
|
||||
start := c.optionsEnd(args)
|
||||
var out []Operand
|
||||
for i := start; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if strings.HasPrefix(arg, "scp://") || strings.HasPrefix(arg, "sftp://") {
|
||||
return nil, fmt.Errorf("%q: use host:path instead of a URI", arg)
|
||||
}
|
||||
dest, path, remote := splitRemote(arg)
|
||||
if !remote {
|
||||
continue
|
||||
}
|
||||
host, user := splitUserHost(dest)
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) > 0 && !strings.EqualFold(out[0].Host, host) {
|
||||
return nil, fmt.Errorf("scp between two remote hosts (%s, %s) is not supported; copy in two steps", out[0].Host, host)
|
||||
}
|
||||
out = append(out, Operand{Idx: i, User: user, Host: host, Path: path})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// optionsEnd returns the index of the first operand; scp, like ssh, stops
|
||||
// option parsing at the first non-option or after "--".
|
||||
func (c Client) optionsEnd(args []string) int {
|
||||
end := c.insertionPoint(args)
|
||||
if end < len(args) && args[end] == "--" {
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// insertionPoint is where added options still count as options: before the
|
||||
// first operand or before "--".
|
||||
func (c Client) insertionPoint(args []string) int {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" {
|
||||
return i
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") || len(arg) < 2 {
|
||||
return i
|
||||
}
|
||||
if c.takesSeparateValue(arg) {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return len(args)
|
||||
}
|
||||
|
||||
// splitRemote mirrors scp's colon(): an argument is remote when a ':' comes
|
||||
// before any '/'. A leading "[" starts a bracketed IPv6 address.
|
||||
func splitRemote(arg string) (dest, path string, remote bool) {
|
||||
if strings.HasPrefix(arg, "/") || strings.HasPrefix(arg, ".") {
|
||||
return "", "", false
|
||||
}
|
||||
inBracket := false
|
||||
for i := 0; i < len(arg); i++ {
|
||||
switch arg[i] {
|
||||
case '[':
|
||||
inBracket = true
|
||||
case ']':
|
||||
inBracket = false
|
||||
case '/':
|
||||
if !inBracket {
|
||||
return "", "", false
|
||||
}
|
||||
case ':':
|
||||
if !inBracket {
|
||||
return arg[:i], arg[i+1:], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// HasForwardFlag reports whether -L, -R, or -D (or the -o equivalents) was given.
|
||||
func (c Client) HasForwardFlag(args []string) bool {
|
||||
return c.hasOption(args, 'L', "LocalForward") || c.hasOption(args, 'R', "RemoteForward") || c.hasOption(args, 'D', "DynamicForward")
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestSFTPParse(t *testing.T) {
|
||||
// -l is a bandwidth limit and -s takes a value for sftp, unlike ssh.
|
||||
parsed := SFTP.Parse([]string{"-l", "100", "-s", "sub", "-P", "2222", "ops@web01:/var/log"})
|
||||
if parsed == nil || parsed.Host != "web01" || parsed.User != "ops" || parsed.Path != "/var/log" || parsed.DestIdx != 6 {
|
||||
t.Fatalf("got %+v", parsed)
|
||||
}
|
||||
if p := SFTP.Parse([]string{"prod/web01"}); p == nil || p.Host != "prod/web01" || p.Path != "" {
|
||||
t.Fatalf("instance-qualified host: %+v", p)
|
||||
}
|
||||
if !SFTP.HasPortFlag([]string{"-P", "2222", "web"}) || SFTP.HasPortFlag([]string{"-p", "web"}) {
|
||||
t.Fatal("sftp port flag is -P")
|
||||
}
|
||||
if SFTP.HasUserFlag([]string{"-l", "100", "web"}) || !SFTP.HasUserFlag([]string{"-o", "User=ops", "web"}) {
|
||||
t.Fatal("sftp has no -l user flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCPOperands(t *testing.T) {
|
||||
ops, err := SCP.Operands([]string{"-X", "nrequests=8", "-r", "./local:dir", "/abs/file", "ops@web01:/tmp/", "web01:b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Operand{{Idx: 5, User: "ops", Host: "web01", Path: "/tmp/"}, {Idx: 6, Host: "web01", Path: "b"}}
|
||||
if !reflect.DeepEqual(ops, want) {
|
||||
t.Fatalf("got %+v", ops)
|
||||
}
|
||||
if ops, _ := SCP.Operands([]string{"a.txt", "b.txt"}); len(ops) != 0 {
|
||||
t.Fatalf("local copy has remote operands: %+v", ops)
|
||||
}
|
||||
if _, err := SCP.Operands([]string{"web01:a", "db01:b"}); err == nil {
|
||||
t.Fatal("two remote hosts accepted")
|
||||
}
|
||||
if _, err := SCP.Operands([]string{"scp://web01/a", "."}); err == nil {
|
||||
t.Fatal("URI accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTPApply(t *testing.T) {
|
||||
args := []string{"-P", "2222", "ops@web01:/var/log"}
|
||||
opts := config.SSHOptions{ProxyJump: "jh", ExtraArgs: []string{"-A"}, Forwards: map[string]config.Forward{"web": {Local: "1:a:2"}}}
|
||||
got := SFTP.Apply(args, []int{2}, Host{Name: "web01", IP: "192.0.2.10"}, opts, WrapperFlags{ForwardArgs: []string{"-L", "1:a:2"}}).Args
|
||||
want := []string{"-P", "2222", "-J", "jh", "ops@192.0.2.10:/var/log"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v (extra_args and forwards are ssh only)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCPApplyReplacesEveryOperandAndBracketsIPv6(t *testing.T) {
|
||||
args := []string{"a.txt", "web01:/tmp/", "ops@web01:b"}
|
||||
got := SCP.Apply(args, []int{1, 2}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{HostKeyChecking: "accept-new"}, WrapperFlags{}).Args
|
||||
want := []string{"-o", "StrictHostKeyChecking=accept-new", "a.txt", "[2001:db8::1]:/tmp/", "ops@[2001:db8::1]:b"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
// sftp needs brackets even without a path; ssh never does.
|
||||
if got := SFTP.Apply([]string{"web01"}, []int{0}, Host{Name: "web01", IP: "2001:db8::1"}, config.SSHOptions{}, WrapperFlags{}).Args; got[0] != "[2001:db8::1]" {
|
||||
t.Fatalf("sftp: %v", got)
|
||||
}
|
||||
if got := apply([]string{"web01"}, config.SSHOptions{}, WrapperFlags{}); got.Args[0] != "192.0.2.10" {
|
||||
t.Fatalf("ssh: %v", got.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSFTPTargetName(t *testing.T) {
|
||||
got := SFTP.Apply([]string{"web01:/srv"}, []int{0}, Host{Name: "web01.example.com", IP: "192.0.2.10"}, config.SSHOptions{Target: "name"}, WrapperFlags{}).Args
|
||||
want := []string{"-o", "HostName=192.0.2.10", "-o", "HostKeyAlias=web01.example.com", "web01.example.com:/srv"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientUserAndPortArgs(t *testing.T) {
|
||||
if got := SSH.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-l", "ops"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := SCP.UserArgs("ops"); !reflect.DeepEqual(got, []string{"-o", "User=ops"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := SFTP.PortArgs("2222"); !reflect.DeepEqual(got, []string{"-P", "2222"}) {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientExtractWrapperFlags(t *testing.T) {
|
||||
// For scp, -X takes a value; for ssh it does not.
|
||||
flags, rest, err := SCP.ExtractWrapperFlags([]string{"-X", "--dry-run", "--dry-run", "a", "web:b"})
|
||||
if err != nil || !flags.DryRun || !reflect.DeepEqual(rest, []string{"-X", "--dry-run", "a", "web:b"}) {
|
||||
t.Fatalf("got %+v %v %v", flags, rest, err)
|
||||
}
|
||||
flags, rest, err = ExtractWrapperFlags([]string{"--forward", "web", "--forward=8080:localhost:80", "-v", "web01"})
|
||||
if err != nil || !reflect.DeepEqual(flags.Forwards, []string{"web", "8080:localhost:80"}) || !reflect.DeepEqual(rest, []string{"-v", "web01"}) {
|
||||
t.Fatalf("got %+v %v %v", flags, rest, err)
|
||||
}
|
||||
for _, args := range [][]string{{"--forward"}, {"--forward", "-L", "web"}} {
|
||||
if _, _, err := ExtractWrapperFlags(args); err == nil {
|
||||
t.Errorf("%v accepted", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardArgs(t *testing.T) {
|
||||
available := map[string]config.Forward{"web": {Local: "8080:localhost:80"}, "socks": {Dynamic: "1080"}, "back": {Remote: "9000:localhost:9000"}}
|
||||
got, err := ForwardArgs([]string{"WEB", "socks", "back", "5432:db:5432", "127.0.0.1:3000:localhost:3000"}, available)
|
||||
want := []string{"-L", "8080:localhost:80", "-D", "1080", "-R", "9000:localhost:9000", "-L", "5432:db:5432", "-L", "127.0.0.1:3000:localhost:3000"}
|
||||
if err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v %v", got, err)
|
||||
}
|
||||
if _, err := ForwardArgs([]string{"db"}, available); err == nil || !strings.Contains(err.Error(), "back, socks, web") {
|
||||
t.Fatalf("unknown name: %v", err)
|
||||
}
|
||||
if _, err := ForwardArgs([]string{"db"}, nil); err == nil || !strings.Contains(err.Error(), "no forwards are configured") {
|
||||
t.Fatalf("no forwards: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyForwardsAndTunnel(t *testing.T) {
|
||||
flags := WrapperFlags{ForwardArgs: []string{"-L", "8080:localhost:80"}}
|
||||
if got := apply([]string{"web"}, config.SSHOptions{}, flags).Args; !reflect.DeepEqual(got, []string{"-L", "8080:localhost:80", "192.0.2.10"}) {
|
||||
t.Fatalf("forward: %v", got)
|
||||
}
|
||||
flags.Tunnel = true
|
||||
result := apply([]string{"web"}, config.SSHOptions{}, flags)
|
||||
want := []string{"-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-N", "192.0.2.10"}
|
||||
if !reflect.DeepEqual(result.Args, want) || !strings.Contains(result.Notice, "forwarding -L 8080:localhost:80 via web.example.com") {
|
||||
t.Fatalf("tunnel: %v %q", result.Args, result.Notice)
|
||||
}
|
||||
// The user's own ExitOnForwardFailure wins.
|
||||
if got := apply([]string{"-o", "ExitOnForwardFailure=no", "web"}, config.SSHOptions{}, flags).Args; strings.Contains(strings.Join(got, " "), "=yes") {
|
||||
t.Fatalf("overrode user option: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasForwardFlag(t *testing.T) {
|
||||
for args, want := range map[string]bool{"-L 1:a:2 web": true, "-vD1080 web": true, "-o RemoteForward=1_a_2 web": true, "-l ops web": false, "web -L 1:a:2": false} {
|
||||
if got := SSH.HasForwardFlag(strings.Fields(args)); got != want {
|
||||
t.Errorf("%q: got %v", args, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInsertsOptionsBeforeDoubleDash(t *testing.T) {
|
||||
got := Apply([]string{"-v", "--", "web"}, 2, Host{Name: "web", IP: "192.0.2.10"}, config.SSHOptions{ProxyJump: "jh"}, WrapperFlags{}).Args
|
||||
if want := []string{"-v", "-J", "jh", "--", "192.0.2.10"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,14 @@ import (
|
||||
|
||||
// Exec replaces the current process with the native ssh client via syscall.Exec.
|
||||
// All existing SSH configs, keys, and agent forwarding remain intact.
|
||||
func Exec(args []string) error {
|
||||
sshPath, err := exec.LookPath("ssh")
|
||||
func Exec(args []string) error { return SSH.Exec(args) }
|
||||
|
||||
// Exec replaces the current process with the client's binary.
|
||||
func (c Client) Exec(args []string) error {
|
||||
path, err := exec.LookPath(c.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh not found in PATH: %w", err)
|
||||
return fmt.Errorf("%s not found in PATH: %w", c.Name, err)
|
||||
}
|
||||
argv := append([]string{"ssh"}, args...)
|
||||
return syscall.Exec(sshPath, argv, os.Environ())
|
||||
argv := append([]string{c.Name}, args...)
|
||||
return syscall.Exec(path, argv, os.Environ())
|
||||
}
|
||||
+99
-16
@@ -12,14 +12,24 @@ import (
|
||||
// WrapperFlags are netssh's own long options. OpenSSH has no long options,
|
||||
// so these never collide with flags meant for ssh.
|
||||
type WrapperFlags struct {
|
||||
DryRun bool
|
||||
Jump string // --jump HOST overrides the configured jump host
|
||||
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
|
||||
DryRun bool
|
||||
Jump string // --jump HOST overrides the configured jump host
|
||||
NoJump bool // --no-jump disables any jump host, including ~/.ssh/config
|
||||
Forwards []string // --forward NAME|SPEC, repeatable (ssh only)
|
||||
// ForwardArgs are Forwards resolved by ForwardArgs; Apply inserts them.
|
||||
ForwardArgs []string
|
||||
// Tunnel runs ssh without a remote command (-N), for netssh tunnel.
|
||||
Tunnel bool
|
||||
}
|
||||
|
||||
// ExtractWrapperFlags removes netssh's long options that appear before the
|
||||
// destination. Anything after the destination belongs to the remote command.
|
||||
func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||
return SSH.ExtractWrapperFlags(args)
|
||||
}
|
||||
|
||||
// ExtractWrapperFlags removes netssh's long options before the destination.
|
||||
func (c Client) ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||
var flags WrapperFlags
|
||||
out := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
@@ -37,6 +47,14 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||
flags.Jump = args[i]
|
||||
case strings.HasPrefix(arg, "--jump="):
|
||||
flags.Jump = strings.TrimPrefix(arg, "--jump=")
|
||||
case arg == "--forward":
|
||||
if i+1 >= len(args) {
|
||||
return flags, nil, fmt.Errorf("--forward requires a name or spec")
|
||||
}
|
||||
i++
|
||||
flags.Forwards = append(flags.Forwards, args[i])
|
||||
case strings.HasPrefix(arg, "--forward="):
|
||||
flags.Forwards = append(flags.Forwards, strings.TrimPrefix(arg, "--forward="))
|
||||
case arg == "--" || !strings.HasPrefix(arg, "-") || len(arg) < 2:
|
||||
// Destination or end of options: keep the rest unchanged.
|
||||
return flags, append(out, args[i:]...), validateJump(flags)
|
||||
@@ -45,7 +63,7 @@ func ExtractWrapperFlags(args []string) (WrapperFlags, []string, error) {
|
||||
default:
|
||||
out = append(out, arg)
|
||||
// Keep the value of an ssh option such as -l USER together with it.
|
||||
if takesSeparateValue(arg) && i+1 < len(args) {
|
||||
if c.takesSeparateValue(arg) && i+1 < len(args) {
|
||||
i++
|
||||
out = append(out, args[i])
|
||||
}
|
||||
@@ -64,14 +82,48 @@ func validateJump(flags WrapperFlags) error {
|
||||
if strings.HasPrefix(flags.Jump, "-") {
|
||||
return fmt.Errorf("--jump: invalid host %q", flags.Jump)
|
||||
}
|
||||
for _, forward := range flags.Forwards {
|
||||
if forward == "" || strings.HasPrefix(forward, "-") || strings.ContainsAny(forward, " \t\n") {
|
||||
return fmt.Errorf("--forward: invalid value %q", forward)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var adHocForward = regexp.MustCompile(`^([^\s:]+:)?[0-9]+(:[^\s]+:[0-9]+)?$`)
|
||||
|
||||
// ForwardArgs resolves forward names against the configured forwards. A value
|
||||
// that is no configured name but looks like a port or [bind:]port:host:port
|
||||
// is used as a local forward (-L).
|
||||
func ForwardArgs(names []string, available map[string]config.Forward) ([]string, error) {
|
||||
var out []string
|
||||
for _, name := range names {
|
||||
if forward, ok := available[strings.ToLower(name)]; ok {
|
||||
out = append(out, forward.Flag()...)
|
||||
continue
|
||||
}
|
||||
if strings.Contains(name, ":") && adHocForward.MatchString(name) {
|
||||
out = append(out, "-L", name)
|
||||
continue
|
||||
}
|
||||
known := make([]string, 0, len(available))
|
||||
for key := range available {
|
||||
known = append(known, key)
|
||||
}
|
||||
sort.Strings(known)
|
||||
if len(known) == 0 {
|
||||
return nil, fmt.Errorf("unknown forward %q; no forwards are configured for this host (ssh.forwards), or pass a spec such as 8080:localhost:80", name)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown forward %q; available: %s", name, strings.Join(known, ", "))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// takesSeparateValue reports whether a short option cluster ends with a flag
|
||||
// whose value is the next argument (e.g. "-l" or "-vp").
|
||||
func takesSeparateValue(arg string) bool {
|
||||
func (c Client) takesSeparateValue(arg string) bool {
|
||||
for j := 1; j < len(arg); j++ {
|
||||
if flagsWithArg[arg[j]] {
|
||||
if c.withArg[arg[j]] {
|
||||
return j == len(arg)-1
|
||||
}
|
||||
}
|
||||
@@ -96,9 +148,17 @@ var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])
|
||||
// options directly before it. Explicit options given by the user come first,
|
||||
// and ssh uses the first -o value it obtains, so the user's choice wins.
|
||||
func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
return SSH.Apply(args, []int{destIdx}, host, opts, flags)
|
||||
}
|
||||
|
||||
// Apply replaces the host in every destination (several only for scp) and
|
||||
// inserts the configured options before the first one. extra_args and
|
||||
// forwards are ssh flags and apply to ssh only.
|
||||
func (c Client) Apply(args []string, dests []int, host Host, opts config.SSHOptions, flags WrapperFlags) Result {
|
||||
destIdx := dests[0]
|
||||
var inserted []string
|
||||
add := func(keyword, value string) {
|
||||
if !hasOption(args[:destIdx], 0, keyword) {
|
||||
if !c.hasOption(args[:destIdx], 0, keyword) {
|
||||
inserted = append(inserted, "-o", keyword+"="+value)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +174,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
|
||||
}
|
||||
}
|
||||
|
||||
userJump := hasOption(args[:destIdx], 'J', "ProxyJump") || hasOption(args[:destIdx], 0, "ProxyCommand")
|
||||
userJump := c.hasOption(args[:destIdx], 'J', "ProxyJump") || c.hasOption(args[:destIdx], 0, "ProxyCommand")
|
||||
switch {
|
||||
case userJump:
|
||||
case flags.NoJump:
|
||||
@@ -140,7 +200,7 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
|
||||
}
|
||||
}
|
||||
|
||||
if opts.AddressFamily != "" && !hasBoolFlag(args[:destIdx], '4') && !hasBoolFlag(args[:destIdx], '6') {
|
||||
if opts.AddressFamily != "" && !c.hasBoolFlag(args[:destIdx], '4') && !c.hasBoolFlag(args[:destIdx], '6') {
|
||||
add("AddressFamily", opts.AddressFamily)
|
||||
}
|
||||
|
||||
@@ -153,19 +213,42 @@ func Apply(args []string, destIdx int, host Host, opts config.SSHOptions, flags
|
||||
add(key, opts.Options[key])
|
||||
}
|
||||
|
||||
var extra []string
|
||||
if c.Name == SSH.Name {
|
||||
extra = opts.ExtraArgs
|
||||
inserted = append(inserted, flags.ForwardArgs...)
|
||||
if flags.Tunnel {
|
||||
add("ExitOnForwardFailure", "yes")
|
||||
inserted = append(inserted, "-N")
|
||||
forwards := strings.Join(flags.ForwardArgs, " ")
|
||||
if forwards == "" {
|
||||
forwards = "the given ports"
|
||||
}
|
||||
tunnel := fmt.Sprintf("forwarding %s via %s, press Ctrl+C to stop", forwards, host.Name)
|
||||
if notice == "" {
|
||||
notice = tunnel
|
||||
} else {
|
||||
notice += "\nnetssh: " + tunnel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extra arguments go first: short flags such as -4 or -A take the last
|
||||
// value given, so the user's own flags later on the line still win.
|
||||
replaced := ReplaceHost(args, destIdx, dest)
|
||||
out := make([]string, 0, len(opts.ExtraArgs)+len(replaced)+len(inserted))
|
||||
out = append(out, opts.ExtraArgs...)
|
||||
out = append(out, replaced[:destIdx]...)
|
||||
// Options go before the first operand: scp stops reading options there,
|
||||
// and after "--" they would be taken as the destination.
|
||||
at := c.insertionPoint(args)
|
||||
replaced := c.replaceHost(args, dests, dest)
|
||||
out := make([]string, 0, len(extra)+len(replaced)+len(inserted))
|
||||
out = append(out, extra...)
|
||||
out = append(out, replaced[:at]...)
|
||||
out = append(out, inserted...)
|
||||
out = append(out, replaced[destIdx:]...)
|
||||
out = append(out, replaced[at:]...)
|
||||
return Result{Args: out, Notice: notice}
|
||||
}
|
||||
|
||||
// hasBoolFlag reports whether a flag without a value, such as -4, was given.
|
||||
func hasBoolFlag(args []string, wanted byte) bool {
|
||||
func (c Client) hasBoolFlag(args []string, wanted byte) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" || len(arg) < 2 || arg[0] != '-' {
|
||||
@@ -175,7 +258,7 @@ func hasBoolFlag(args []string, wanted byte) bool {
|
||||
if arg[j] == wanted {
|
||||
return true
|
||||
}
|
||||
if flagsWithArg[arg[j]] {
|
||||
if c.withArg[arg[j]] {
|
||||
if j == len(arg)-1 {
|
||||
i++
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestExtractWrapperFlags(t *testing.T) {
|
||||
{[]string{"--no-jump", "--", "web"}, WrapperFlags{NoJump: true}, []string{"--", "web"}},
|
||||
} {
|
||||
flags, rest, err := ExtractWrapperFlags(tc.args)
|
||||
if err != nil || flags != tc.flags || !reflect.DeepEqual(rest, tc.rest) {
|
||||
if err != nil || !reflect.DeepEqual(flags, tc.flags) || !reflect.DeepEqual(rest, tc.rest) {
|
||||
t.Errorf("%v: got %+v %v %v", tc.args, flags, rest, err)
|
||||
}
|
||||
}
|
||||
|
||||
+117
-17
@@ -22,13 +22,22 @@ import (
|
||||
|
||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||
type SelectedHost struct {
|
||||
Kind string
|
||||
Name string
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
Port string // empty = use default port
|
||||
Kind string
|
||||
Name string
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
Port string // empty = use default port
|
||||
Action string // ActionSSH, ActionSFTP, or ActionTunnel
|
||||
Forwards []string // forward names or specs for ActionTunnel
|
||||
}
|
||||
|
||||
// Actions the user can start on a host.
|
||||
const (
|
||||
ActionSSH = ""
|
||||
ActionSFTP = "sftp"
|
||||
ActionTunnel = "tunnel"
|
||||
)
|
||||
|
||||
// --- bubbletea messages ---
|
||||
|
||||
type debounceMsg struct {
|
||||
@@ -145,6 +154,9 @@ type Model struct {
|
||||
input textinput.Model
|
||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||
editInput textinput.Model // user@host:port inline editor
|
||||
forwardInput textinput.Model // forward names/specs, ctrl+t
|
||||
forwardHost *hostItem
|
||||
forwardsFor func(name, kind string, tags []string) []string
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
@@ -160,6 +172,7 @@ type Model struct {
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // Ctrl+E on the selected item
|
||||
forwardMode bool // Ctrl+T on the selected item
|
||||
}
|
||||
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||
@@ -174,6 +187,10 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
||||
ei := textinput.New()
|
||||
ei.Prompt = ""
|
||||
|
||||
fwd := textinput.New()
|
||||
fwd.Prompt = ""
|
||||
fwd.Placeholder = "web, 8080:localhost:80"
|
||||
|
||||
l := list.New(nil, compactDelegate{}, 0, 0)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowTitle(false)
|
||||
@@ -189,16 +206,17 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
||||
ni.CharLimit = 0
|
||||
ni.SetHeight(10)
|
||||
return &Model{
|
||||
noteInput: ni,
|
||||
defaultPort: port,
|
||||
input: ti,
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
defaultUser: defaultUser,
|
||||
recentMode: true,
|
||||
noteInput: ni,
|
||||
defaultPort: port,
|
||||
input: ti,
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
forwardInput: fwd,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
defaultUser: defaultUser,
|
||||
recentMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +299,30 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// --- Forward mode ---
|
||||
if m.forwardMode {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.forwardMode = false
|
||||
m.forwardInput.Blur()
|
||||
m.input.Focus()
|
||||
m.err = nil
|
||||
return m, nil
|
||||
case "enter":
|
||||
item := *m.forwardHost
|
||||
forwards, err := parseForwards(m.forwardInput.Value(), m.availableForwards(item))
|
||||
if err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionTunnel, Forwards: forwards}
|
||||
return m, tea.Quit
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.forwardInput, cmd = m.forwardInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// --- Edit mode ---
|
||||
if m.editMode {
|
||||
switch msg.String() {
|
||||
@@ -355,6 +397,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+o":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind, Action: ActionSFTP}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "ctrl+t":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.forwardMode = true
|
||||
m.forwardHost = &item
|
||||
m.err = nil
|
||||
m.input.Blur()
|
||||
m.forwardInput.SetValue("")
|
||||
m.forwardInput.Focus()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+n", "ctrl+b":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
||||
m.noteMode = true
|
||||
@@ -509,13 +568,23 @@ func (m *Model) viewContent() string {
|
||||
sb.WriteString(m.list.View())
|
||||
}
|
||||
|
||||
if m.editMode {
|
||||
if m.forwardMode {
|
||||
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||
sb.WriteString("\n" + dim.Render("Forward via "+m.forwardHost.name+": "))
|
||||
sb.WriteString(m.forwardInput.View() + "\n")
|
||||
available := "none configured — enter specs such as 8080:localhost:80"
|
||||
if names := m.availableForwards(*m.forwardHost); len(names) > 0 {
|
||||
available = strings.Join(names, ", ")
|
||||
}
|
||||
sb.WriteString(dim.Render(" forwards: "+available) + "\n")
|
||||
sb.WriteString(dim.Render(" enter start tunnel esc cancel") + "\n")
|
||||
} else if m.editMode {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
||||
sb.WriteString(m.editInput.View() + "\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||
} else {
|
||||
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||
}
|
||||
|
||||
@@ -654,3 +723,34 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||
}
|
||||
|
||||
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||
|
||||
// SetForwards provides the forward names configured for a host (ctrl+t).
|
||||
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
|
||||
m.forwardsFor = forwardsFor
|
||||
}
|
||||
|
||||
func (m *Model) availableForwards(item hostItem) []string {
|
||||
if m.forwardsFor == nil {
|
||||
return nil
|
||||
}
|
||||
return m.forwardsFor(item.name, item.kind, item.tags)
|
||||
}
|
||||
|
||||
// parseForwards accepts configured names and specs containing a colon,
|
||||
// separated by commas or spaces.
|
||||
func parseForwards(input string, available []string) ([]string, error) {
|
||||
known := make(map[string]bool, len(available))
|
||||
for _, name := range available {
|
||||
known[strings.ToLower(name)] = true
|
||||
}
|
||||
fields := strings.FieldsFunc(input, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' })
|
||||
if len(fields) == 0 {
|
||||
return nil, fmt.Errorf("enter a forward name or a spec such as 8080:localhost:80")
|
||||
}
|
||||
for _, field := range fields {
|
||||
if !known[strings.ToLower(field)] && !strings.Contains(field, ":") {
|
||||
return nil, fmt.Errorf("unknown forward %q", field)
|
||||
}
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -70,3 +72,53 @@ func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
||||
t.Fatalf("edited host changed: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlOSelectsSFTP(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1"}})
|
||||
m.Update(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
|
||||
if m.selected == nil || m.selected.Action != ActionSFTP || m.selected.Name != "web01" {
|
||||
t.Fatalf("got %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlTStartsTunnelWithForwards(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.SetForwards(func(name, kind string, tags []string) []string {
|
||||
if name != "web01" || len(tags) != 1 {
|
||||
t.Errorf("forwards asked for %s %v", name, tags)
|
||||
}
|
||||
return []string{"web"}
|
||||
})
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "web01", PrimaryIP4: "10.0.0.1", Tags: []string{"dmz"}}})
|
||||
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
|
||||
if !m.forwardMode || m.selected != nil {
|
||||
t.Fatal("forward input not opened")
|
||||
}
|
||||
if !strings.Contains(m.viewContent(), "forwards: web") {
|
||||
t.Fatalf("configured forwards not shown:\n%s", m.viewContent())
|
||||
}
|
||||
|
||||
m.forwardInput.SetValue("nope")
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected != nil || m.err == nil {
|
||||
t.Fatal("unknown forward accepted")
|
||||
}
|
||||
|
||||
m.forwardInput.SetValue("WEB, 9000:localhost:9000")
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
want := []string{"WEB", "9000:localhost:9000"}
|
||||
if m.selected == nil || m.selected.Action != ActionTunnel || !reflect.DeepEqual(m.selected.Forwards, want) {
|
||||
t.Fatalf("got %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForwardInputEscCancels(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "web01"}})
|
||||
m.Update(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
|
||||
if m.forwardMode || m.selected != nil {
|
||||
t.Fatal("esc did not cancel")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user