Files
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

104 lines
3.0 KiB
Go

package main
import (
"context"
"fmt"
"io"
"strings"
"time"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
"github.com/spf13/cobra"
)
func notesCmd() *cobra.Command {
var remote bool
var kind string
cmd := &cobra.Command{Use: "notes", Short: "Read and write server notes (local by default)"}
cmd.PersistentFlags().BoolVar(&remote, "netbox", false, "Use the NetBox comments field instead of local notes")
cmd.PersistentFlags().StringVar(&kind, "kind", "", "Host kind: device or vm (required for uncached local hosts)")
for _, action := range []string{"show", "set", "append"} {
sub := &cobra.Command{Use: action + " <host> [text]", Short: action + " a server note", Args: cobra.RangeArgs(1, 2)}
if action == "show" {
sub.Args = cobra.ExactArgs(1)
}
sub.RunE = func(cmd *cobra.Command, args []string) error {
if kind != "" && kind != "vm" && kind != "device" {
return fmt.Errorf("invalid kind %q", kind)
}
cfg, err := config.Load()
if err != nil {
return err
}
var client *netbox.Client
if cfg.HasNetBox() {
client = newNetBoxClient(cfg)
}
host := netbox.HostEntry{Name: args[0], Kind: kind}
c := newCache(cfg)
if err := c.Load(); err != nil {
return err
}
var cached []cache.Entry
if !remote {
cached = c.All()
}
query := host.Name
source, rawName := netbox.SplitTarget(query)
var matches []cache.Entry
for _, entry := range cached {
_, entryName := netbox.SplitTarget(entry.Name)
if (kind == "" || kind == entry.Kind) && (strings.EqualFold(entry.Name, query) || (source == "" && strings.EqualFold(entryName, rawName))) {
matches = append(matches, entry)
}
}
if len(matches) > 1 {
return fmt.Errorf("ambiguous hostname %q; use instance/hostname", query)
}
if len(matches) == 1 {
host.Name = matches[0].Name
host.Kind = matches[0].Kind
}
if !remote && host.Kind == "" {
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
}
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
defer cancel()
service := notes.New(cfg.NetBox.URL, client)
note, err := service.Read(ctx, host, remote)
if err != nil {
return err
}
if action == "show" {
_, err := fmt.Fprint(cmd.OutOrStdout(), note.Text)
return err
}
var text string
if len(args) == 2 {
text = args[1]
} else {
data, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1024*1024+1))
if err != nil {
return err
}
if len(data) > 1024*1024 {
return fmt.Errorf("note exceeds 1 MiB")
}
text = string(data)
}
if action == "append" && note.Text != "" {
note.Text = strings.TrimRight(note.Text, "\n") + "\n" + text
} else {
note.Text = text
}
return service.Write(ctx, note, remote)
}
cmd.AddCommand(sub)
}
return cmd
}