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>
285 lines
8.9 KiB
Go
285 lines
8.9 KiB
Go
package setup
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"charm.land/huh/v2"
|
|
|
|
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
|
)
|
|
|
|
// inheritOption leaves a setting empty so a broader layer (or ssh itself) decides.
|
|
func inheritOption() huh.Option[string] { return huh.NewOption("(inherit)", "") }
|
|
|
|
// editSSHOptions edits one SSH option layer: global, per instance, or per rule.
|
|
func editSSHOptions(title string, o config.SSHOptions) (config.SSHOptions, error) {
|
|
proxyJump := o.ProxyJump
|
|
hostKeyChecking := o.HostKeyChecking
|
|
target := o.Target
|
|
addressFamily := o.AddressFamily
|
|
extraArgs := strings.Join(o.ExtraArgs, "\n")
|
|
options := formatOptions(o.Options)
|
|
forwards := formatForwards(o.Forwards)
|
|
|
|
err := huh.NewForm(huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Jump host").
|
|
Description("Passed as -J, e.g. admin@bastion.example.com.\nLeave empty to inherit; 'none' disables a jump host set elsewhere.").
|
|
Value(&proxyJump).
|
|
Validate(func(s string) error {
|
|
return config.SSHOptions{ProxyJump: strings.TrimSpace(s)}.Validate("ssh")
|
|
}),
|
|
huh.NewSelect[string]().
|
|
Title("Host key checking").
|
|
Options(inheritOption(), huh.NewOption("strict", "strict"), huh.NewOption("accept-new", "accept-new"), huh.NewOption("off", "off")).
|
|
Value(&hostKeyChecking),
|
|
huh.NewSelect[string]().
|
|
Title("Connection target").
|
|
Description("What is passed to ssh as destination.").
|
|
Options(inheritOption(), huh.NewOption("ip — resolved address", "ip"), huh.NewOption("name — NetBox name", "name"), huh.NewOption("hostname — name as typed", "hostname")).
|
|
Value(&target),
|
|
huh.NewSelect[string]().
|
|
Title("Address family").
|
|
Options(inheritOption(), huh.NewOption("any", "any"), huh.NewOption("inet (IPv4)", "inet"), huh.NewOption("inet6 (IPv6)", "inet6")).
|
|
Value(&addressFamily),
|
|
huh.NewText().
|
|
Title("Extra arguments").
|
|
Description("One argument per line, inserted before the destination, e.g. -A").
|
|
Lines(3).
|
|
Value(&extraArgs),
|
|
huh.NewText().
|
|
Title("SSH options").
|
|
Description("One Key=Value per line, passed as -o, e.g. ServerAliveInterval=60").
|
|
Lines(4).
|
|
Value(&options).
|
|
Validate(func(s string) 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,
|
|
AddressFamily: addressFamily,
|
|
ExtraArgs: parseLines(extraArgs),
|
|
Options: parsed,
|
|
}, nil
|
|
}
|
|
|
|
// parseLines returns the non-empty, trimmed lines of s.
|
|
func parseLines(s string) []string {
|
|
var out []string
|
|
for _, line := range strings.Split(s, "\n") {
|
|
if line = strings.TrimSpace(line); line != "" {
|
|
out = append(out, line)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseOptions reads Key=Value lines into the map stored under ssh.options.
|
|
func parseOptions(s string) (map[string]string, error) {
|
|
var out map[string]string
|
|
for _, line := range parseLines(s) {
|
|
key, value, ok := strings.Cut(line, "=")
|
|
key = strings.TrimSpace(key)
|
|
if !ok || key == "" {
|
|
return nil, fmt.Errorf("%q: expected Key=Value", line)
|
|
}
|
|
if out == nil {
|
|
out = make(map[string]string)
|
|
}
|
|
out[key] = strings.TrimSpace(value)
|
|
}
|
|
if err := (config.SSHOptions{Options: out}).Validate("ssh"); err != nil {
|
|
return nil, err
|
|
}
|
|
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 {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
lines := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
lines = append(lines, key+"="+options[key])
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// configureRules edits a copy of the rules; the caller decides whether to keep it.
|
|
func configureRules(rules []config.SSHRule, sources []config.NetBoxConfig) ([]config.SSHRule, error) {
|
|
rules = append([]config.SSHRule(nil), rules...)
|
|
for {
|
|
action := "done"
|
|
var options []huh.Option[string]
|
|
for i, rule := range rules {
|
|
index := strconv.Itoa(i)
|
|
options = append(options, huh.NewOption("Edit rule "+describeMatch(rule.Match), "edit:"+index))
|
|
options = append(options, huh.NewOption("Remove rule "+describeMatch(rule.Match), "remove:"+index))
|
|
}
|
|
options = append(options, huh.NewOption("Add rule", "add"), huh.NewOption("Back", "done"))
|
|
err := huh.NewForm(huh.NewGroup(huh.NewSelect[string]().
|
|
Title("SSH rules").
|
|
Description("Rules apply SSH options to hosts matching every set field. Later rules win.").
|
|
Options(options...).
|
|
Value(&action))).Run()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if action == "done" {
|
|
return rules, nil
|
|
}
|
|
if action == "add" {
|
|
rule, err := editRule(config.SSHRule{}, sources)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rules = append(rules, rule)
|
|
continue
|
|
}
|
|
verb, index, _ := strings.Cut(action, ":")
|
|
i, _ := strconv.Atoi(index)
|
|
if verb == "edit" {
|
|
rule, err := editRule(rules[i], sources)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rules[i] = rule
|
|
continue
|
|
}
|
|
remove := false
|
|
if err := huh.NewConfirm().Title("Remove rule " + describeMatch(rules[i].Match) + "?").Value(&remove).Run(); err != nil {
|
|
return nil, err
|
|
}
|
|
if remove {
|
|
rules = append(rules[:i], rules[i+1:]...)
|
|
}
|
|
}
|
|
}
|
|
|
|
func editRule(rule config.SSHRule, sources []config.NetBoxConfig) (config.SSHRule, error) {
|
|
match := rule.Match
|
|
instances := []huh.Option[string]{huh.NewOption("(any)", "")}
|
|
known := false
|
|
for _, source := range sources {
|
|
instances = append(instances, huh.NewOption(source.Name, source.Name))
|
|
known = known || strings.EqualFold(source.Name, match.Instance)
|
|
}
|
|
if match.Instance != "" && !known {
|
|
instances = append(instances, huh.NewOption(match.Instance+" (not configured)", match.Instance))
|
|
}
|
|
|
|
err := huh.NewForm(huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Tag").
|
|
Description("NetBox tag name or slug, e.g. dmz").
|
|
Value(&match.Tag),
|
|
huh.NewSelect[string]().
|
|
Title("Instance").
|
|
Options(instances...).
|
|
Value(&match.Instance),
|
|
huh.NewSelect[string]().
|
|
Title("Kind").
|
|
Options(huh.NewOption("(any)", ""), huh.NewOption("device", "device"), huh.NewOption("vm", "vm")).
|
|
Value(&match.Kind),
|
|
huh.NewInput().
|
|
Title("Name pattern").
|
|
Description("Shell-style glob without instance prefix, e.g. *.dmz.example.com").
|
|
Value(&match.Name).
|
|
Validate(func(s string) error {
|
|
candidate := match
|
|
candidate.Tag = strings.TrimSpace(candidate.Tag)
|
|
candidate.Name = strings.TrimSpace(s)
|
|
return candidate.Validate("rule")
|
|
}),
|
|
).Title("Rule match")).Run()
|
|
if err != nil {
|
|
return rule, err
|
|
}
|
|
match.Tag = strings.TrimSpace(match.Tag)
|
|
match.Name = strings.TrimSpace(match.Name)
|
|
|
|
options, err := editSSHOptions("SSH options for rule "+describeMatch(match), rule.SSHOptions)
|
|
if err != nil {
|
|
return rule, err
|
|
}
|
|
return config.SSHRule{Match: match, SSHOptions: options}, nil
|
|
}
|
|
|
|
func describeMatch(m config.SSHMatch) string {
|
|
var parts []string
|
|
for _, field := range [][2]string{{"tag", m.Tag}, {"instance", m.Instance}, {"kind", m.Kind}, {"name", m.Name}} {
|
|
if field[1] != "" {
|
|
parts = append(parts, field[0]+"="+field[1])
|
|
}
|
|
}
|
|
return "[" + strings.Join(parts, " ") + "]"
|
|
}
|