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
+162
-66
@@ -42,6 +42,9 @@ var managedSubcommands = map[string]bool{
|
||||
"help": true,
|
||||
"version": true,
|
||||
"uninstall": true,
|
||||
"sftp": true,
|
||||
"scp": true,
|
||||
"tunnel": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -91,47 +94,106 @@ func runSSHWrapper(args []string) {
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
runClient(internalssh.SSH, flags, args)
|
||||
}
|
||||
|
||||
// destination is the NetBox host named in the arguments of a client.
|
||||
type destination struct {
|
||||
host string
|
||||
user string
|
||||
dests []int // argument indices naming the host; several only for scp
|
||||
}
|
||||
|
||||
func findDestination(client internalssh.Client, args []string) (*destination, error) {
|
||||
if client.Name == internalssh.SCP.Name {
|
||||
operands, err := client.Operands(args)
|
||||
if err != nil || len(operands) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
dest := &destination{host: operands[0].Host}
|
||||
for _, operand := range operands {
|
||||
dest.dests = append(dest.dests, operand.Idx)
|
||||
if operand.User != "" {
|
||||
dest.user = operand.User
|
||||
}
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
parsed := client.Parse(args)
|
||||
if parsed == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &destination{host: parsed.Host, user: parsed.User, dests: []int{parsed.DestIdx}}, nil
|
||||
}
|
||||
|
||||
// runClient resolves the NetBox host in args and execs ssh, sftp, or scp.
|
||||
func runClient(client internalssh.Client, flags internalssh.WrapperFlags, args []string) {
|
||||
cfg := loadConfigOrSetup()
|
||||
|
||||
parsed := internalssh.Parse(args)
|
||||
if parsed == nil {
|
||||
// No destination found — pass arguments straight through to ssh.
|
||||
execSSH(internalssh.Result{Args: args}, flags)
|
||||
return
|
||||
dest, err := findDestination(client, args)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
connect := func(name, kind, ip string, tags []string) {
|
||||
connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args)
|
||||
if dest == nil {
|
||||
if len(flags.Forwards) > 0 {
|
||||
fatalf("--forward needs a destination host")
|
||||
}
|
||||
// No destination found — pass arguments straight through.
|
||||
execClient(client, internalssh.Result{Args: args}, flags)
|
||||
return
|
||||
}
|
||||
|
||||
// Inject the configured default user if none was given on the command line.
|
||||
if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) {
|
||||
args = append([]string{"-l", cfg.SSH.DefaultUser}, args...)
|
||||
parsed.DestIdx += 2
|
||||
if cfg.SSH.DefaultUser != "" && dest.user == "" && !client.HasUserFlag(args) {
|
||||
args = prependArgs(client.UserArgs(cfg.SSH.DefaultUser), args, dest.dests)
|
||||
}
|
||||
// Inject the configured default port if none was given on the command line.
|
||||
if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) {
|
||||
args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...)
|
||||
parsed.DestIdx += 2
|
||||
if cfg.SSH.DefaultPort > 0 && !client.HasPortFlag(args) {
|
||||
args = prependArgs(client.PortArgs(strconv.Itoa(cfg.SSH.DefaultPort)), args, dest.dests)
|
||||
}
|
||||
|
||||
connectHost(cfg, client, flags, resolveTarget(cfg, dest.host), dest.dests, args)
|
||||
}
|
||||
|
||||
// prependArgs puts extra before args and shifts the destination indices.
|
||||
func prependArgs(extra, args []string, dests []int) []string {
|
||||
for i := range dests {
|
||||
dests[i] += len(extra)
|
||||
}
|
||||
return append(append([]string(nil), extra...), args...)
|
||||
}
|
||||
|
||||
// resolvedHost is a NetBox host with the address to connect to.
|
||||
type resolvedHost struct {
|
||||
Name string // cache/selector name, may carry an instance prefix
|
||||
Kind string
|
||||
IP string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// resolveTarget finds host in the cache (also by shortcut) or NetBox and
|
||||
// resolves its address. It exits on failure.
|
||||
func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
maybeBackgroundRefresh(cfg, c)
|
||||
|
||||
source, _ := netbox.SplitTarget(parsed.Host)
|
||||
source, _ := netbox.SplitTarget(host)
|
||||
useCache := len(cfg.NetBoxes) == 0 || source != ""
|
||||
used := func(entry cache.Entry) resolvedHost {
|
||||
c.MarkUsed(entry.Name)
|
||||
_ = c.Save()
|
||||
return resolvedHost{Name: entry.Name, Kind: entry.Kind, IP: entry.IP, Tags: entry.Tags}
|
||||
}
|
||||
|
||||
// Cache hit with a fresh TTL — connect directly without querying NetBox.
|
||||
if entry, fresh := c.Get(parsed.Host); fresh && useCache {
|
||||
c.MarkUsed(parsed.Host)
|
||||
_ = c.Save()
|
||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||
return
|
||||
if entry, fresh := c.Get(host); fresh && useCache {
|
||||
return used(entry)
|
||||
}
|
||||
|
||||
// Shortcut cache lookup: scan entries whose normalized name matches the input.
|
||||
// If a stale match is found, use the canonical name for the NetBox re-fetch below.
|
||||
lookupHost := parsed.Host
|
||||
lookupHost := host
|
||||
shortcutsEnabled := cfg.Shortcuts.Enabled()
|
||||
if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache {
|
||||
normalize := normalizeTarget(cfg.Shortcuts, source != "")
|
||||
@@ -141,10 +203,7 @@ func runSSHWrapper(args []string) {
|
||||
}
|
||||
if found {
|
||||
if fresh {
|
||||
c.MarkUsed(entry.Name)
|
||||
_ = c.Save()
|
||||
connect(entry.Name, entry.Kind, entry.IP, entry.Tags)
|
||||
return
|
||||
return used(entry)
|
||||
}
|
||||
// Stale shortcut match — use canonical name for NetBox re-fetch.
|
||||
lookupHost = entry.Name
|
||||
@@ -161,7 +220,7 @@ func runSSHWrapper(args []string) {
|
||||
|
||||
target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains)
|
||||
if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled {
|
||||
entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false))
|
||||
entry, found, _, cacheErr := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, false))
|
||||
if cacheErr != nil {
|
||||
fatalf("%v", cacheErr)
|
||||
}
|
||||
@@ -174,10 +233,7 @@ func runSSHWrapper(args []string) {
|
||||
}
|
||||
|
||||
if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind {
|
||||
c.MarkUsed(target.Target())
|
||||
_ = c.Save()
|
||||
connect(cached.Name, cached.Kind, cached.IP, cached.Tags)
|
||||
return
|
||||
return used(cached)
|
||||
}
|
||||
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
@@ -187,14 +243,12 @@ func runSSHWrapper(args []string) {
|
||||
|
||||
ip, err := chain.Resolve(ctx, &target, nbClient)
|
||||
if err != nil {
|
||||
fatalf("IP resolution for %q: %v", parsed.Host, err)
|
||||
fatalf("IP resolution for %q: %v", host, err)
|
||||
}
|
||||
|
||||
c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags})
|
||||
c.MarkUsed(target.Target())
|
||||
_ = c.Save()
|
||||
|
||||
connect(target.Target(), target.Kind, ip, target.Tags)
|
||||
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
|
||||
c.Upsert(entry)
|
||||
return used(entry)
|
||||
}
|
||||
|
||||
// findSSHHost uses the paginated, ambiguity-checking lookup for connection
|
||||
@@ -217,24 +271,43 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain
|
||||
return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name)
|
||||
}
|
||||
|
||||
// connectHost applies the SSH options configured for the host and execs ssh.
|
||||
// target is the cache/selector name, which may carry an instance prefix.
|
||||
func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) {
|
||||
instance, name := netbox.SplitTarget(target)
|
||||
opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags})
|
||||
execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags)
|
||||
// connectHost applies the SSH options configured for the host and execs the client.
|
||||
func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) {
|
||||
opts := hostOptions(cfg, host)
|
||||
if len(flags.Forwards) > 0 {
|
||||
forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards)
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
flags.ForwardArgs = forwardArgs
|
||||
}
|
||||
if flags.Tunnel && len(flags.ForwardArgs) == 0 && !client.HasForwardFlag(args) {
|
||||
available := "none configured (ssh.forwards)"
|
||||
if names := forwardNames(opts); len(names) > 0 {
|
||||
available = strings.Join(names, ", ")
|
||||
}
|
||||
fatalf("name a forward or a spec such as 8080:localhost:80; forwards for %s: %s", host.Name, available)
|
||||
}
|
||||
_, name := netbox.SplitTarget(host.Name)
|
||||
execClient(client, client.Apply(args, dests, internalssh.Host{Name: name, IP: host.IP}, opts, flags), flags)
|
||||
}
|
||||
|
||||
// execSSH replaces the process with ssh, or prints the command for --dry-run.
|
||||
func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) {
|
||||
// hostOptions merges the global, instance, and rule SSH options for host.
|
||||
func hostOptions(cfg *config.Config, host resolvedHost) config.SSHOptions {
|
||||
instance, name := netbox.SplitTarget(host.Name)
|
||||
return cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: host.Kind, Tags: host.Tags})
|
||||
}
|
||||
|
||||
// execClient replaces the process with the client, or prints the command for --dry-run.
|
||||
func execClient(client internalssh.Client, result internalssh.Result, flags internalssh.WrapperFlags) {
|
||||
if flags.DryRun {
|
||||
fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...)))
|
||||
fmt.Println(internalssh.Quote(append([]string{client.Name}, result.Args...)))
|
||||
return
|
||||
}
|
||||
if result.Notice != "" {
|
||||
fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice)
|
||||
}
|
||||
if err := internalssh.Exec(result.Args); err != nil {
|
||||
if err := client.Exec(result.Args); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -254,6 +327,9 @@ func runTUI() {
|
||||
|
||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||
m.SetForwards(func(name, kind string, tags []string) []string {
|
||||
return forwardNames(hostOptions(cfg, resolvedHost{Name: name, Kind: kind, Tags: tags}))
|
||||
})
|
||||
p := tea.NewProgram(m)
|
||||
final, err := p.Run()
|
||||
if err != nil {
|
||||
@@ -297,31 +373,54 @@ func runTUI() {
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||
|
||||
var sshArgs []string
|
||||
c.MarkUsed(host.Name)
|
||||
_ = c.Save()
|
||||
|
||||
client, flags, args := tuiCommand(cfg, host)
|
||||
connectHost(cfg, client, flags, resolvedHost{Name: host.Name, Kind: host.Kind, IP: host.IP, Tags: tags}, []int{len(args) - 1}, args)
|
||||
}
|
||||
|
||||
// tuiCommand builds the client and arguments for the action chosen in the TUI.
|
||||
func tuiCommand(cfg *config.Config, host *tui.SelectedHost) (internalssh.Client, internalssh.WrapperFlags, []string) {
|
||||
client := internalssh.SSH
|
||||
var flags internalssh.WrapperFlags
|
||||
switch host.Action {
|
||||
case tui.ActionSFTP:
|
||||
client = internalssh.SFTP
|
||||
case tui.ActionTunnel:
|
||||
flags = internalssh.WrapperFlags{Forwards: host.Forwards, Tunnel: true}
|
||||
}
|
||||
|
||||
var args []string
|
||||
// User override from TUI edit mode takes priority, then config default.
|
||||
user := host.User
|
||||
if user == "" {
|
||||
user = cfg.SSH.DefaultUser
|
||||
}
|
||||
if user != "" {
|
||||
sshArgs = append(sshArgs, "-l", user)
|
||||
args = append(args, client.UserArgs(user)...)
|
||||
}
|
||||
|
||||
port := host.Port
|
||||
if port == "" && cfg.SSH.DefaultPort > 0 {
|
||||
port = strconv.Itoa(cfg.SSH.DefaultPort)
|
||||
}
|
||||
if port != "" {
|
||||
sshArgs = append(sshArgs, "-p", port)
|
||||
args = append(args, client.PortArgs(port)...)
|
||||
}
|
||||
// Apply replaces the destination; a bare name cannot be mistaken for
|
||||
// host:path by sftp, unlike an IPv6 address.
|
||||
_, name := netbox.SplitTarget(host.Name)
|
||||
return client, flags, append(args, name)
|
||||
}
|
||||
|
||||
sshArgs = append(sshArgs, host.IP)
|
||||
|
||||
c.MarkUsed(host.Name)
|
||||
_ = c.Save()
|
||||
|
||||
connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs)
|
||||
// forwardNames lists the forwards configured for a host, sorted.
|
||||
func forwardNames(opts config.SSHOptions) []string {
|
||||
names := make([]string, 0, len(opts.Forwards))
|
||||
for name := range opts.Forwards {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// --- cobra root + subcommands ---
|
||||
@@ -332,17 +431,14 @@ func rootCmd() *cobra.Command {
|
||||
Short: "SSH wrapper with NetBox hostname resolution",
|
||||
DisableFlagParsing: true,
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
c := cache.New(cfg.CachePath(), cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
|
||||
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
|
||||
hosts = hosts[:limit]
|
||||
if len(args) > 0 && args[len(args)-1] == "--forward" {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveError
|
||||
}
|
||||
return filterPrefix(allForwardNames(cfg), toComplete, nil), cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
hosts := completeHosts(toComplete)
|
||||
completions := make([]cobra.Completion, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
completions = append(completions, cobra.Completion(host))
|
||||
@@ -357,7 +453,7 @@ func rootCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd())
|
||||
|
||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||
root.InitDefaultCompletionCmd()
|
||||
|
||||
Reference in new issue
Block a user