Files
ssh-netbox-wrapper/cmd/netssh/transfer.go
Sebastian UnterschützandClaude Opus 5.5 a950a82552 feat: hostname resolver strategy and static hosts
Add the hostname resolver strategy. It connects by the NetBox name
instead of an IP, with resolver.hostname_domain appended to names
without a dot. Its position in the chain decides whether it is always
used or only as a fallback (e.g. [primary_ip, hostname]); names that
are not valid DNS hostnames fall through to the next strategy.

Add static hosts under hosts: for machines that are not in NetBox,
with address, user, port, tags, and their own SSH options. They are
checked before NetBox (by name or shortcut), never expire, survive
cache refresh and clear, and work without any NetBox configured. The
cache overlays them after every load and stores only their last use,
so they appear in completion, aliases, the TUI and its recent list.
SSH options merge global, static host, then rules; rules can match
kind: static. Manage them with netssh hosts list|add|remove and in
netssh configure, which can now also continue without NetBox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 23:46:13 +01:00

199 lines
6.3 KiB
Go

package main
import (
"sort"
"strings"
"github.com/spf13/cobra"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh"
)
func sftpCmd() *cobra.Command {
return transferCmd(internalssh.SFTP, "sftp [--dry-run] [--jump HOST|--no-jump] [sftp options] HOST[:PATH]",
"Open an SFTP session to a NetBox host",
`Resolve HOST like netssh does for ssh (cache, shortcuts, instance/host,
SSH options and rules, jump host) and run sftp. sftp options are passed
through; note that sftp uses -P for the port.
netssh sftp fsnbx1
netssh sftp -P 2222 prod/web01:/var/log`)
}
func scpCmd() *cobra.Command {
return transferCmd(internalssh.SCP, "scp [--dry-run] [--jump HOST|--no-jump] [scp options] SOURCE... TARGET",
"Copy files to or from a NetBox host",
`Resolve the host in HOST:PATH operands like netssh does for ssh and run scp.
All remote operands must name the same host.
netssh scp ./app.tar fsnbx1:/tmp/
netssh scp -r prod/web01:/etc/nginx ./nginx`)
}
func transferCmd(client internalssh.Client, use, short, long string) *cobra.Command {
return &cobra.Command{
Use: use,
Short: short,
Long: long,
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_ = cmd.Help()
return
}
flags, args, err := client.ExtractWrapperFlags(args)
if err != nil {
fatalf("%v", err)
}
if len(flags.Forwards) > 0 {
fatalf("--forward is only available for ssh and netssh tunnel")
}
runClient(client, flags, args)
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if client.Name == internalssh.SFTP.Name && client.Parse(args) != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if strings.HasPrefix(toComplete, "-") {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if client.Name == internalssh.SCP.Name {
if strings.Contains(toComplete, ":") {
return nil, cobra.ShellCompDirectiveNoFileComp // remote paths are not completed
}
// HOST: for remote operands; without a matching host, local files.
hosts := completeHosts(toComplete)
if len(hosts) == 0 {
return nil, cobra.ShellCompDirectiveDefault
}
for i := range hosts {
hosts[i] += ":"
}
return hosts, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
},
}
}
func tunnelCmd() *cobra.Command {
return &cobra.Command{
Use: "tunnel [--dry-run] [--jump HOST|--no-jump] [ssh options] HOST FORWARD...",
Short: "Open port forwards to a NetBox host without a shell",
Long: `Run ssh -N with the named forwards configured under ssh.forwards (globally,
per NetBox instance, or per rule). A FORWARD that is no configured name but a
port spec such as 8080:localhost:80 is used as a local forward (-L).
netssh tunnel fsnbx1 web socks
netssh tunnel prod/db01 5432:localhost:5432`,
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 || args[0] == "--help" || args[0] == "-h" {
_ = cmd.Help()
return
}
flags, args, err := internalssh.SSH.ExtractWrapperFlags(args)
if err != nil {
fatalf("%v", err)
}
parsed := internalssh.SSH.Parse(args)
if parsed == nil {
fatalf("usage: netssh tunnel HOST FORWARD...")
}
// Everything after the host names forwards instead of a remote command.
flags.Forwards = append(flags.Forwards, args[parsed.DestIdx+1:]...)
flags.Tunnel = true
runClient(internalssh.SSH, flags, args[:parsed.DestIdx+1])
},
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
parsed := internalssh.SSH.Parse(args)
if parsed == nil {
return completeHosts(toComplete), cobra.ShellCompDirectiveNoFileComp
}
cfg, err := config.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
var names []string
if entry, ok := cachedHost(cfg, parsed.Host); ok {
names = forwardNames(hostOptions(cfg, resolvedHost{Name: entry.Name, Kind: entry.Kind, Tags: entry.Tags}))
} else {
names = allForwardNames(cfg)
}
return filterPrefix(names, toComplete, args[parsed.DestIdx+1:]), cobra.ShellCompDirectiveNoFileComp
},
}
}
// completeHosts returns the cached hosts for a completion request.
func completeHosts(toComplete string) []string {
cfg, err := config.Load()
if err != nil {
return nil
}
c := newCache(cfg)
_ = c.Load()
hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts)
if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit {
hosts = hosts[:limit]
}
return hosts
}
// cachedHost finds a host for completion by name or shortcut, without NetBox.
func cachedHost(cfg *config.Config, host string) (cache.Entry, bool) {
c := newCache(cfg)
if err := c.Load(); err != nil {
return cache.Entry{}, false
}
if entry, _ := c.Get(host); entry.Name != "" {
return entry, true
}
if !cfg.Shortcuts.Enabled() {
return cache.Entry{}, false
}
entry, found, _, err := c.GetByShortcut(host, normalizeTarget(cfg.Shortcuts, strings.Contains(host, "/")))
return entry, found && err == nil
}
// allForwardNames lists every configured forward name, for when the host is unknown.
func allForwardNames(cfg *config.Config) []string {
seen := map[string]bool{}
add := func(o config.SSHOptions) {
for name := range o.Forwards {
seen[strings.ToLower(name)] = true
}
}
add(cfg.SSH.SSHOptions)
add(cfg.NetBox.SSH)
for _, source := range cfg.NetBoxes {
add(source.SSH)
}
for _, rule := range cfg.SSH.Rules {
add(rule.SSHOptions)
}
names := make([]string, 0, len(seen))
for name := range seen {
names = append(names, name)
}
sort.Strings(names)
return names
}
// filterPrefix keeps the names starting with prefix that were not given yet.
func filterPrefix(names []string, prefix string, given []string) []string {
used := map[string]bool{}
for _, name := range given {
used[strings.ToLower(name)] = true
}
var out []string
for _, name := range names {
if strings.HasPrefix(name, strings.ToLower(prefix)) && !used[name] {
out = append(out, name)
}
}
return out
}