feat(ssh): jump hosts, host key checking, and configurable ssh options
Add SSH settings that apply only to netssh connections, so ~/.ssh/config does not need global changes: - proxy_jump (passed as -J; "none" disables an inherited jump host) - host_key_checking: strict | accept-new | off (off prints a notice) - target: ip (default) | name | hostname; "name" connects to the NetBox name with -o HostName=<ip> and -o HostKeyAlias=<name>, so `Host *.example.com` rules apply and known_hosts entries follow the device instead of a reused IP - address_family: any | inet | inet6 - options (-o Key=Value) and extra_args Settings layer global -> netboxes[].ssh -> ssh.rules (match by tag, instance, kind, or name glob). Options given on the command line always win: -o values are inserted after the user's own, and extra_args before them. New wrapper flags: --jump HOST, --no-jump, and --dry-run, which prints the final ssh command instead of connecting. The TUI uses the same options. The setup wizard keeps all new settings when it saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
129a7201bc
commit
2b46c4f74d
8 files changed
+738
-19
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -246,10 +247,18 @@ func save(cfg config.Config) error {
|
||||
b.WriteString("netboxes:\n")
|
||||
for _, source := range cfg.NetBoxes {
|
||||
fmt.Fprintf(&b, " - name: %q\n url: %q\n token: %q\n token_version: %d\n", source.Name, source.URL, source.Token, source.TokenVersion)
|
||||
if !sshOptionsEmpty(source.SSH) {
|
||||
b.WriteString(" ssh:\n")
|
||||
writeSSHOptions(&b, " ", source.SSH)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b.WriteString("netbox:\n")
|
||||
fmt.Fprintf(&b, " url: %q\n token: %q\n token_version: %d\n", cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
if !sshOptionsEmpty(cfg.NetBox.SSH) {
|
||||
b.WriteString(" ssh:\n")
|
||||
writeSSHOptions(&b, " ", cfg.NetBox.SSH)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\nresolver:\n")
|
||||
@@ -280,6 +289,23 @@ func save(cfg config.Config) error {
|
||||
if cfg.SSH.DefaultPort > 0 {
|
||||
fmt.Fprintf(&b, " default_port: %d\n", cfg.SSH.DefaultPort)
|
||||
}
|
||||
writeSSHOptions(&b, " ", cfg.SSH.SSHOptions)
|
||||
if len(cfg.SSH.Rules) > 0 {
|
||||
b.WriteString(" rules:\n")
|
||||
for _, rule := range cfg.SSH.Rules {
|
||||
b.WriteString(" - match:\n")
|
||||
for _, field := range [][2]string{{"tag", rule.Match.Tag}, {"instance", rule.Match.Instance}, {"kind", rule.Match.Kind}, {"name", rule.Match.Name}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(&b, " %s: %q\n", field[0], field[1])
|
||||
}
|
||||
}
|
||||
writeSSHOptions(&b, " ", rule.SSHOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Completion.MaxResults > 0 {
|
||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||
}
|
||||
|
||||
if len(cfg.Shortcuts.Domains) > 0 || cfg.Shortcuts.StripHyphens {
|
||||
b.WriteString("\nshortcuts:\n")
|
||||
@@ -305,3 +331,33 @@ func save(cfg config.Config) error {
|
||||
fmt.Printf("\nConfig saved → %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sshOptionsEmpty(o config.SSHOptions) bool {
|
||||
return o.ProxyJump == "" && o.HostKeyChecking == "" && o.Target == "" && o.AddressFamily == "" && len(o.ExtraArgs) == 0 && len(o.Options) == 0
|
||||
}
|
||||
|
||||
// writeSSHOptions keeps settings the wizard does not ask for when it rewrites the config.
|
||||
func writeSSHOptions(b *strings.Builder, indent string, o config.SSHOptions) {
|
||||
for _, field := range [][2]string{{"proxy_jump", o.ProxyJump}, {"host_key_checking", o.HostKeyChecking}, {"target", o.Target}, {"address_family", o.AddressFamily}} {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(b, "%s%s: %q\n", indent, field[0], field[1])
|
||||
}
|
||||
}
|
||||
if len(o.ExtraArgs) > 0 {
|
||||
fmt.Fprintf(b, "%sextra_args:\n", indent)
|
||||
for _, arg := range o.ExtraArgs {
|
||||
fmt.Fprintf(b, "%s - %q\n", indent, arg)
|
||||
}
|
||||
}
|
||||
if len(o.Options) > 0 {
|
||||
keys := make([]string, 0, len(o.Options))
|
||||
for key := range o.Options {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
fmt.Fprintf(b, "%soptions:\n", indent)
|
||||
for _, key := range keys {
|
||||
fmt.Fprintf(b, "%s %s: %q\n", indent, key, o.Options[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package setup
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -408,7 +409,7 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||
t.Fatalf("instances lost during save: %+v", loaded)
|
||||
}
|
||||
for i, want := range cfg.NetBoxes {
|
||||
if loaded.NetBoxes[i] != want {
|
||||
if !reflect.DeepEqual(loaded.NetBoxes[i], want) {
|
||||
t.Fatalf("instance changed: %+v, want %+v", loaded.NetBoxes[i], want)
|
||||
}
|
||||
}
|
||||
@@ -420,3 +421,37 @@ func TestSaveMultipleNetBoxesRoundTrip(t *testing.T) {
|
||||
t.Fatal("credentials are not private")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveKeepsSSHOptions(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
opts := config.SSHOptions{
|
||||
ProxyJump: "jh.example.com",
|
||||
HostKeyChecking: "accept-new",
|
||||
Target: "name",
|
||||
AddressFamily: "inet",
|
||||
ExtraArgs: []string{"-A"},
|
||||
Options: map[string]string{"serveraliveinterval": "60"},
|
||||
}
|
||||
cfg := config.Config{
|
||||
NetBoxes: []config.NetBoxConfig{{Name: "prod", URL: "https://prod.example", Token: "t", TokenVersion: 1, SSH: config.SSHOptions{HostKeyChecking: "off"}}},
|
||||
Resolver: config.ResolverConfig{Strategies: []string{"primary_ip"}},
|
||||
Cache: config.CacheConfig{TTL: 3600},
|
||||
SSH: config.SSHConfig{
|
||||
DefaultUser: "root",
|
||||
SSHOptions: opts,
|
||||
Rules: []config.SSHRule{{Match: config.SSHMatch{Tag: "dmz", Name: "*.dmz"}, SSHOptions: config.SSHOptions{ProxyJump: "none"}}},
|
||||
},
|
||||
Completion: config.CompletionConfig{MaxResults: 100},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.SSH, cfg.SSH) || !reflect.DeepEqual(loaded.NetBoxes[0].SSH, cfg.NetBoxes[0].SSH) || loaded.Completion != cfg.Completion {
|
||||
t.Fatalf("ssh settings lost:\n%+v\n%+v", loaded.SSH, loaded.NetBoxes[0].SSH)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user