diff --git a/README.md b/README.md index 92dfc7d..4bbe74d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ netssh -p 2222 admin@app-server-03 uptime - **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments) - **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed - **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm` -- **User/port override** — press `e` in the TUI to override the SSH user or port before connecting +- **User/port override** — press `Ctrl+E` in the TUI to override the SSH user or port before connecting +- **Server notes** — multiline local notes or optional NetBox comments, available in the TUI and CLI - **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion - **Full pagination** — `cache refresh` fetches all hosts from NetBox (not just the first 50) - **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced @@ -235,7 +236,9 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search | `Tab` | autocomplete top result into the search field | | `↑` / `↓` | navigate results | | `Enter` | connect to selected host | -| `e` | open inline editor to override user/port before connecting | +| `Ctrl+E` | open inline editor to override user/port before connecting | +| `Ctrl+N` | edit local notes for the selected server | +| `Ctrl+B` | edit NetBox comments for the selected server | | `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) | | `Esc` / `Ctrl+C` | quit (or close filter/edit if open) | @@ -247,13 +250,48 @@ Filter: tag:prod kind:vm Multiple `tag:` values are AND-combined. The filter is applied locally against the cache; when doing a live NetBox search the first tag is also forwarded as a query parameter. -**User/port override** — press `e` on any highlighted host: +**User/port override** — press `Ctrl+E` on any highlighted host: ``` Connect as: admin@my-router:22 ``` -Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. Port 22 is treated as default and omitted from the ssh command. +Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. The configured default port is pre-filled. An explicit port 22 overrides a different configured default. + +### Server notes + +Select a server in the TUI and press `Ctrl+N` for local notes, or `Ctrl+B` for +NetBox comments. Both open a multiline editor: `Ctrl+S` saves, `Esc` cancels. +Existing text is loaded before editing. A failed save keeps your draft in the editor. +Local notes and NetBox comments are independent; there is no automatic synchronization. + +The CLI also supports reading, replacing, and appending notes: + +```sh +netssh notes set web01 "Maintenance on Friday" +netssh notes append web01 "Reboot completed" +netssh notes show web01 +printf 'First line\nSecond line\n' | netssh notes set web01 +netssh notes append web01 --netbox --kind vm "Maintenance completed" +netssh notes show web01 --netbox --kind vm +``` + +`set` replaces the selected note; `append` preserves existing text and adds a new line. +Omitting the text reads it from standard input. An empty string clears the note. +For an uncached local host, specify `--kind device` or `--kind vm`; local operations +work offline. Use the full server name as displayed in the TUI. + +Local notes live under `~/.config/netssh/notes/` (`$XDG_CONFIG_HOME` is respected), +with file permissions `0600` and atomic file replacement. They are keyed by NetBox +URL, host kind, and hostname, so cache refreshes and `cache clear` preserve them. +Changing the hostname or NetBox URL creates a separate note identity. + +`--netbox` explicitly selects the device/VM's shared `comments` field and requires +a token with write permissions. Existing comments are loaded before modification; +ambiguous names are rejected (specify `--kind` where appropriate). +The client uses the API's ETag/If-Match conflict protection when the server provides +it; older NetBox versions without ETags use the server's last-write-wins behavior. +See the [NetBox REST API documentation](https://netbox.readthedocs.io/en/stable/integrations/rest-api/). ### Cache management diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 5689964..9ba813c 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -19,6 +19,7 @@ import ( "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts" @@ -29,6 +30,7 @@ import ( // managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode. var managedSubcommands = map[string]bool{ "configure": true, + "notes": true, "search": true, "cache": true, "alias": true, @@ -213,7 +215,8 @@ func runTUI() { nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) } - m := tui.New(nbClient, c, cfg.SSH.DefaultUser) + m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort) + m.SetNotes(notes.New(cfg.NetBox.URL, nbClient)) p := tea.NewProgram(m, tea.WithAltScreen()) final, err := p.Run() if err != nil { @@ -229,6 +232,29 @@ func runTUI() { return } + // Revalidate stale or live TUI results through the configured resolver chain. + if cached, fresh := c.Get(host.Name); fresh { + host.IP = cached.IP + } else { + if nbClient == nil { + fatalf("cannot resolve %q: NetBox is not configured", host.Name) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + target, err := nbClient.FindHost(ctx, host.Name, host.Kind) + if err != nil { + fatalf("NetBox: %v", err) + } + chain, err := resolver.New(cfg.Resolver) + if err != nil { + fatalf("resolver: %v", err) + } + host.IP, err = chain.Resolve(ctx, &target, nbClient) + if err != nil { + fatalf("IP resolution: %v", err) + } + c.Upsert(cache.Entry{Name: target.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags}) + } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) var sshArgs []string @@ -289,7 +315,7 @@ func rootCmd() *cobra.Command { }, } - root.AddCommand(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd()) + root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd()) // cobra builds the "completion" command lazily; force init so we can extend it. root.InitDefaultCompletionCmd() @@ -493,7 +519,11 @@ func cacheRefreshCmd() *cobra.Command { c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) _ = c.Load() - chain, _ := resolver.New(cfg.Resolver) + chain, err := resolver.New(cfg.Resolver) + if err != nil { + return fmt.Errorf("resolver: %w", err) + } + written := 0 for i := range entries { e := &entries[i] @@ -512,21 +542,24 @@ func cacheRefreshCmd() *cobra.Command { if ip != "" { c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags}) + written++ } } if err := c.Save(); err != nil { return err } - if err := c.SetRefreshed(); err != nil { - fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err) + if filterTag == "" && filterKind == "" { + if err := c.SetRefreshed(); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err) + } } if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 { if err := hook.WriteAliasFiles(aliasEntries); err != nil { fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) } } - fmt.Printf("%d entries written to cache.\n", len(entries)) + fmt.Printf("%d entries written to cache.\n", written) return nil }, } diff --git a/cmd/netssh/notes.go b/cmd/netssh/notes.go new file mode 100644 index 0000000..41f25af --- /dev/null +++ b/cmd/netssh/notes.go @@ -0,0 +1,93 @@ +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 + " [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.NetBox.URL != "" { + client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion) + } + host := netbox.HostEntry{Name: args[0], Kind: kind} + c := cache.New(cfg.Cache.Path, cfg.Cache.TTL) + if err := c.Load(); err != nil { + return err + } + var cached []cache.Entry + if !remote { + cached = c.All() + } + for _, entry := range cached { + if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) { + host.Name = entry.Name + host.Kind = entry.Kind + break + } + } + 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 +} diff --git a/cmd/netssh/notes_test.go b/cmd/netssh/notes_test.go new file mode 100644 index 0000000..1270f9f --- /dev/null +++ b/cmd/netssh/notes_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "bytes" + "path/filepath" + "strings" + "testing" +) + +func TestLocalNotesCLI(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json")) + run := func(args []string, input string) (string, error) { + root := rootCmd() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetIn(strings.NewReader(input)) + root.SetArgs(args) + err := root.Execute() + return out.String(), err + } + if _, err := run([]string{"notes", "set", "web01", "--kind", "vm"}, "First\nSecond"); err != nil { + t.Fatal(err) + } + if _, err := run([]string{"notes", "append", "web01", "Third", "--kind", "vm"}, ""); err != nil { + t.Fatal(err) + } + out, err := run([]string{"notes", "show", "web01", "--kind", "vm"}, "") + if err != nil || out != "First\nSecond\nThird" { + t.Fatalf("show: %q, %v", out, err) + } + if _, err := run([]string{"notes", "set", "web01", "", "--kind", "vm"}, ""); err != nil { + t.Fatal(err) + } + out, err = run([]string{"notes", "show", "web01", "--kind", "vm"}, "") + if err != nil || out != "" { + t.Fatalf("clear: %q, %v", out, err) + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index b09f5b1..d2a871e 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -2,6 +2,7 @@ package cache import ( "encoding/json" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" "os" "path/filepath" "sort" @@ -116,12 +117,15 @@ func (c *Cache) Save() error { if err != nil { return err } - return os.WriteFile(c.path, data, 0o644) + return storage.WriteFile(c.path, data, 0o600) } func (c *Cache) Upsert(e Entry) { e.CachedAt = time.Now() c.mu.Lock() + if old, ok := c.entries[e.Name]; ok && e.LastUsed.IsZero() { + e.LastUsed = old.LastUsed + } c.entries[e.Name] = e c.mu.Unlock() } diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index c57da31..6af19c8 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -471,3 +471,15 @@ func tempFile(t *testing.T, content []byte) string { f.Close() return f.Name() } + +func TestUpsertPreservesUsageHistory(t *testing.T) { + c := New("", 3600) + c.Upsert(Entry{Name: "web01", IP: "10.0.0.1"}) + c.MarkUsed("web01") + before, _ := c.Get("web01") + c.Upsert(Entry{Name: "web01", IP: "10.0.0.2"}) + after, _ := c.Get("web01") + if after.LastUsed != before.LastUsed || after.IP != "10.0.0.2" { + t.Fatalf("history lost: %+v", after) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 892d91f..3d2f7f3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,7 +69,13 @@ func Load() (*Config, error) { } v.SetEnvPrefix("NETSSH") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() + for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens"} { + if err := v.BindEnv(key); err != nil { + return nil, err + } + } if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 26db733..99ac5d3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -143,3 +143,17 @@ func TestPath_ReturnsNonEmpty(t *testing.T) { t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p)) } } + +func TestEnvironmentOverridesWithoutConfigKeys(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("NETSSH_NETBOX_URL", "https://env.example") + t.Setenv("NETSSH_NETBOX_TOKEN", "nbt_env") + t.Setenv("NETSSH_SSH_DEFAULT_PORT", "2222") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.NetBox.URL != "https://env.example" || cfg.NetBox.Token != "nbt_env" || cfg.NetBox.TokenVersion != 2 || cfg.SSH.DefaultPort != 2222 { + t.Fatalf("env ignored: %+v", cfg) + } +} diff --git a/internal/netbox/client.go b/internal/netbox/client.go index a4fc840..56ac9b5 100644 --- a/internal/netbox/client.go +++ b/internal/netbox/client.go @@ -1,13 +1,17 @@ package netbox import ( + "bytes" "context" "encoding/json" + "errors" "fmt" + "io" "net/http" "net/url" "strings" "sync" + "time" ) type Client struct { @@ -27,13 +31,16 @@ func NewClient(baseURL, token string, tokenVersion int) *Client { baseURL: strings.TrimRight(baseURL, "/"), token: token, tokenVersion: tokenVersion, - httpClient: &http.Client{}, + httpClient: &http.Client{Timeout: 15 * time.Second}, } } // Search queries up to 50 devices and VMs in parallel and merges the results. // Use SearchOptions to restrict by kind or tag. func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) { + if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { + return nil, fmt.Errorf("invalid host kind %q", opts.Kind) + } var ( mu sync.Mutex results []HostEntry @@ -88,16 +95,17 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ( // SearchAll paginates through all matching devices and VMs, fetching every page. // Intended for cache refresh; use Search for interactive queries. func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) { + if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" { + return nil, fmt.Errorf("invalid host kind %q", opts.Kind) + } var ( mu sync.Mutex results []HostEntry errs []error wg sync.WaitGroup - started int ) if opts.Kind != "vm" { - started++ wg.Add(1) go func() { defer wg.Done() @@ -113,7 +121,6 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions } if opts.Kind != "device" { - started++ wg.Add(1) go func() { defer wg.Done() @@ -130,11 +137,8 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions wg.Wait() - if len(errs) == started { - if started == 1 { - return nil, errs[0] - } - return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1]) + if len(errs) > 0 { + return nil, errors.Join(errs...) } return results, nil } @@ -265,16 +269,31 @@ func TokenVersion(token string) int { } func (c *Client) get(ctx context.Context, apiURL string, out any) error { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + _, err := c.request(ctx, http.MethodGet, apiURL, nil, "", out) + return err +} + +func (c *Client) request(ctx context.Context, method, apiURL string, body io.Reader, etag string, out any) (string, error) { + req, err := http.NewRequestWithContext(ctx, method, apiURL, body) if err != nil { - return fmt.Errorf("creating request: %w", err) + return "", fmt.Errorf("creating request: %w", err) + } + scheme := "Token " + if c.tokenVersion == 2 { + scheme = "Bearer " + } + req.Header.Set("Authorization", scheme+c.token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if etag != "" { + req.Header.Set("If-Match", etag) } - req.Header.Set("Authorization", "Token "+c.token) req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { - return fmt.Errorf("request to %s: %w", apiURL, err) + return "", fmt.Errorf("request to %s: %w", apiURL, err) } defer resp.Body.Close() @@ -283,17 +302,17 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error { if c.tokenVersion == 1 { hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)" } - return fmt.Errorf("%s: %s", apiURL, hint) + return "", fmt.Errorf("%s: %s", apiURL, hint) } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL) + return "", fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL) } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { - return fmt.Errorf("decoding response: %w", err) + return "", fmt.Errorf("decoding response: %w", err) } - return nil + return resp.Header.Get("ETag"), nil } func deviceToEntry(d netboxDevice) HostEntry { @@ -331,3 +350,66 @@ func stripPrefix(cidr string) string { } return cidr } + +// FindHost refuses ambiguous names so writes cannot target an arbitrary server. +func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) { + if kind != "" && kind != "device" && kind != "vm" { + return HostEntry{}, fmt.Errorf("invalid kind %q", kind) + } + entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind}) + if err != nil { + return HostEntry{}, err + } + var matches []HostEntry + for _, e := range entries { + if strings.EqualFold(e.Name, name) { + matches = append(matches, e) + } + } + if len(matches) != 1 { + return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches)) + } + return matches[0], nil +} + +func (c *Client) hostURL(host HostEntry) (string, error) { + var path string + switch host.Kind { + case "device": + path = "dcim/devices" + case "vm": + path = "virtualization/virtual-machines" + default: + return "", fmt.Errorf("invalid host kind %q", host.Kind) + } + if host.ID <= 0 { + return "", fmt.Errorf("invalid host ID") + } + return fmt.Sprintf("%s/api/%s/%d/", c.baseURL, path, host.ID), nil +} + +func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) { + u, err := c.hostURL(host) + if err != nil { + return "", "", err + } + var out struct { + Comments string `json:"comments"` + } + etag, err := c.request(ctx, http.MethodGet, u, nil, "", &out) + return out.Comments, etag, err +} + +func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error { + u, err := c.hostURL(host) + if err != nil { + return err + } + data, err := json.Marshal(map[string]string{"comments": comments}) + if err != nil { + return err + } + var out json.RawMessage + _, err = c.request(ctx, http.MethodPatch, u, bytes.NewReader(data), etag, &out) + return err +} diff --git a/internal/netbox/client_test.go b/internal/netbox/client_test.go index d098e1e..a167107 100644 --- a/internal/netbox/client_test.go +++ b/internal/netbox/client_test.go @@ -318,21 +318,26 @@ func Test403_V2Token_NoV1Hint(t *testing.T) { } func TestGet_SendsAuthorizationHeader(t *testing.T) { - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - w.Header().Set("Content-Type", "application/json") - b, _ := json.Marshal(deviceListResponse()) - w.Write(b) - })) - defer srv.Close() - - c := NewClient(srv.URL, "nbt_mytoken", 2) - c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck - - want := "Token nbt_mytoken" - if gotAuth != want { - t.Errorf("Authorization header: got %q, want %q", gotAuth, want) + for _, token := range []string{"nbt_mytoken", "legacytoken"} { + t.Run(token, func(t *testing.T) { + want := "Token " + token + if strings.HasPrefix(token, "nbt_") { + want = "Bearer " + token + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != want { + t.Errorf("Authorization: got %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + b, _ := json.Marshal(deviceListResponse()) + w.Write(b) + })) + defer srv.Close() + c := NewClient(srv.URL, token, 0) + if _, err := c.Search(context.Background(), "", SearchOptions{}); err != nil { + t.Fatal(err) + } + }) } } @@ -434,3 +439,63 @@ func TestStripPrefix(t *testing.T) { } } } + +func TestCommentsPreserveFieldAndSendConflictHeader(t *testing.T) { + for _, kind := range []string{"device", "vm"} { + t.Run(kind, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer nbt_test" { + t.Error("wrong authentication") + } + if r.Method == http.MethodPatch { + if r.Header.Get("If-Match") != `W/"123"` { + t.Error("missing conflict protection") + } + var payload map[string]string + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + if len(payload) != 1 || payload["comments"] != "new\nnotes" { + t.Errorf("unexpected patch: %v", payload) + } + } + w.Header().Set("ETag", `W/"123"`) + w.Write([]byte(`{"comments":"existing notes"}`)) + })) + defer srv.Close() + c := NewClient(srv.URL, "nbt_test", 0) + host := HostEntry{ID: 1, Kind: kind} + text, etag, err := c.GetComments(context.Background(), host) + if err != nil || text != "existing notes" { + t.Fatalf("read: %q, %v", text, err) + } + if err := c.SetComments(context.Background(), host, "new\nnotes", etag); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestFullRefreshRejectsPartialFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "virtualization") { + w.WriteHeader(http.StatusForbidden) + return + } + w.Write([]byte(`{"count":0,"results":[]}`)) + })) + defer srv.Close() + c := NewClient(srv.URL, "token", 0) + if _, err := c.SearchAll(context.Background(), "", SearchOptions{}); err == nil { + t.Fatal("partial refresh reported as successful") + } +} + +func TestCommentConflictIsReported(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPreconditionFailed) })) + defer srv.Close() + c := NewClient(srv.URL, "token", 0) + if err := c.SetComments(context.Background(), HostEntry{ID: 1, Kind: "device"}, "draft", "old"); err == nil { + t.Fatal("conflict ignored") + } +} diff --git a/internal/notes/notes.go b/internal/notes/notes.go new file mode 100644 index 0000000..677e6f1 --- /dev/null +++ b/internal/notes/notes.go @@ -0,0 +1,71 @@ +// Package notes stores private server notes independently of the host cache. +package notes + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" +) + +type Note struct { + Text, ETag string + Host netbox.HostEntry +} +type Service struct { + Dir, Namespace string + Client *netbox.Client +} + +func New(namespace string, client *netbox.Client) *Service { + dir, err := os.UserConfigDir() + if err != nil { + dir = filepath.Join(os.Getenv("HOME"), ".config") + } + return &Service{Dir: filepath.Join(dir, "netssh", "notes"), Namespace: strings.TrimRight(namespace, "/"), Client: client} +} + +func (s *Service) path(host netbox.HostEntry) string { + key := fmt.Sprintf("%s\x00%s\x00%s", s.Namespace, host.Kind, strings.ToLower(host.Name)) + return filepath.Join(s.Dir, fmt.Sprintf("%x.txt", sha256.Sum256([]byte(key)))) +} + +func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool) (Note, error) { + note := Note{Host: host} + if remote { + if s.Client == nil { + return note, fmt.Errorf("NetBox is not configured") + } + if host.ID == 0 { + resolved, err := s.Client.FindHost(ctx, host.Name, host.Kind) + if err != nil { + return note, err + } + note.Host = resolved + } + var err error + note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host) + return note, err + } + data, err := os.ReadFile(s.path(host)) + if os.IsNotExist(err) { + return note, nil + } + note.Text = string(data) + return note, err +} + +func (s *Service) Write(ctx context.Context, note Note, remote bool) error { + if remote { + if s.Client == nil { + return fmt.Errorf("NetBox is not configured") + } + return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag) + } + return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600) +} diff --git a/internal/notes/notes_test.go b/internal/notes/notes_test.go new file mode 100644 index 0000000..86f1281 --- /dev/null +++ b/internal/notes/notes_test.go @@ -0,0 +1,52 @@ +package notes + +import ( + "context" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" + "os" + "testing" +) + +func TestLocalNotesPersistAndStayIsolated(t *testing.T) { + ctx := context.Background() + s := &Service{Dir: t.TempDir(), Namespace: "https://netbox.example"} + host := netbox.HostEntry{Name: "web01", Kind: "device"} + n := Note{Host: host, Text: "Wartung\nGrüße 🔧"} + if err := s.Write(ctx, n, false); err != nil { + t.Fatal(err) + } + reloaded := &Service{Dir: s.Dir, Namespace: s.Namespace} + got, err := reloaded.Read(ctx, host, false) + if err != nil || got.Text != n.Text { + t.Fatalf("round trip: %+v, %v", got, err) + } + info, err := os.Stat(s.path(host)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("permissions: %v", info.Mode()) + } + host.Kind = "vm" + got, err = s.Read(ctx, host, false) + if err != nil || got.Text != "" { + t.Fatalf("kind isolation: %+v, %v", got, err) + } + host.Kind = "device" + reloaded.Namespace = "https://other.example" + got, err = reloaded.Read(ctx, host, false) + if err != nil || got.Text != "" { + t.Fatalf("instance isolation: %+v, %v", got, err) + } +} + +func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) { + s := &Service{Dir: t.TempDir()} + host := netbox.HostEntry{Name: "web01", Kind: "vm"} + if err := os.Mkdir(s.path(host), 0o700); err != nil { + t.Fatal(err) + } + if _, err := s.Read(context.Background(), host, false); err == nil { + t.Fatal("expected read error") + } +} diff --git a/internal/ssh/args.go b/internal/ssh/args.go index 63939be..06648a0 100644 --- a/internal/ssh/args.go +++ b/internal/ssh/args.go @@ -88,6 +88,9 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string { // HasPortFlag reports whether a port was specified via -p in args. func HasPortFlag(args []string) bool { + if parsed := Parse(args); parsed != nil { + args = args[:parsed.DestIdx] + } for i, a := range args { if a == "-p" && i+1 < len(args) { return true @@ -102,6 +105,9 @@ func HasPortFlag(args []string) bool { // HasUserFlag reports whether a user was specified via -l in args. // Used to avoid overriding an explicit -l with the configured default user. func HasUserFlag(args []string) bool { + if parsed := Parse(args); parsed != nil { + args = args[:parsed.DestIdx] + } for i, a := range args { if a == "-l" && i+1 < len(args) { return true diff --git a/internal/ssh/args_test.go b/internal/ssh/args_test.go index afd8b78..7cd1086 100644 --- a/internal/ssh/args_test.go +++ b/internal/ssh/args_test.go @@ -190,3 +190,10 @@ func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int) t.Errorf("destIdx: got %d, want %d", got.DestIdx, destIdx) } } + +func TestRemoteCommandFlagsDoNotOverrideDefaults(t *testing.T) { + args := []string{"web01", "command", "-p", "1234", "-lroot"} + if HasPortFlag(args) || HasUserFlag(args) { + t.Fatal("remote command interpreted as SSH options") + } +} diff --git a/internal/storage/file.go b/internal/storage/file.go new file mode 100644 index 0000000..e6a92cb --- /dev/null +++ b/internal/storage/file.go @@ -0,0 +1,32 @@ +// Package storage provides atomic file replacement in the destination directory. +package storage + +import ( + "os" + "path/filepath" +) + +func WriteFile(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".netssh-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + defer f.Close() + if err := f.Chmod(mode); err != nil { + return err + } + if _, err := f.Write(data); err != nil { + return err + } + if err := f.Sync(); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), path) +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 5bab4bd..a615ad6 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -8,7 +8,9 @@ import ( "strings" "time" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -19,6 +21,7 @@ import ( // SelectedHost is returned when the user confirms a host in the TUI. type SelectedHost struct { + Kind string Name string IP string User string // empty = use config default @@ -27,10 +30,19 @@ type SelectedHost struct { // --- bubbletea messages --- -type debounceMsg struct{ query string } +type debounceMsg struct { + query string + seq int +} +type noteResultMsg struct { + note notes.Note + err error + saved bool +} type searchResultMsg struct { query string + seq int entries []netbox.HostEntry err error recent bool // true when this is a recently-used list, not a search result @@ -40,6 +52,7 @@ type searchResultMsg struct { type hostItem struct { name string + id int ip string kind string tags []string @@ -120,27 +133,35 @@ func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry { // --- Model --- type Model struct { - input textinput.Model - filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f - editInput textinput.Model // user@host:port inline editor - list list.Model - client *netbox.Client - cache *cache.Cache - defaultUser string - lastSent string // last query sent to NetBox (or served from cache) - lastResults []netbox.HostEntry // raw results before filter applied - seq int // sequence number to discard stale results - loading bool - err error - selected *SelectedHost - width int - height int - recentMode bool // true when showing recent hosts (empty search, initial state) - filterOpen bool // Ctrl+F toggles - editMode bool // 'e' on selected item + noteErr error + editHost *hostItem + noteInput textarea.Model + noteService *notes.Service + note notes.Note + noteMode, noteRemote, noteBusy bool + noteLoaded bool + defaultPort int + input textinput.Model + filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f + editInput textinput.Model // user@host:port inline editor + list list.Model + client *netbox.Client + cache *cache.Cache + defaultUser string + lastSent string // last query sent to NetBox (or served from cache) + lastResults []netbox.HostEntry // raw results before filter applied + seq int // sequence number to discard stale results + loading bool + err error + selected *SelectedHost + width int + height int + recentMode bool // true when showing recent hosts (empty search, initial state) + filterOpen bool // Ctrl+F toggles + editMode bool // 'e' on selected item } -func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model { +func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model { ti := textinput.New() ti.Placeholder = "Search hostname…" ti.Focus() @@ -157,8 +178,18 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model { l.SetShowTitle(false) l.SetShowStatusBar(false) l.SetFilteringEnabled(false) + l.DisableQuitKeybindings() + port := 22 + if len(defaultPorts) > 0 && defaultPorts[0] > 0 { + port = defaultPorts[0] + } + ni := textarea.New() + ni.CharLimit = 0 + ni.SetHeight(10) return &Model{ + noteInput: ni, + defaultPort: port, input: ti, filterInput: fi, editInput: ei, @@ -191,6 +222,20 @@ func (m *Model) loadRecent() tea.Cmd { func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case noteResultMsg: + m.noteBusy = false + m.noteErr = msg.err + if msg.err == nil { + m.noteLoaded = true + m.note = msg.note + m.noteInput.SetValue(msg.note.Text) + if msg.saved { + m.noteMode = false + m.noteInput.Blur() + m.input.Focus() + } + } + return m, nil case tea.WindowSizeMsg: m.width = msg.Width @@ -202,10 +247,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.editMode { extraRows += 2 } - m.list.SetSize(msg.Width, msg.Height-extraRows) + m.list.SetSize(msg.Width, max(1, msg.Height-extraRows)) + m.noteInput.SetWidth(max(10, msg.Width-4)) return m, nil case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + if m.noteMode { + if m.noteBusy { + return m, nil + } + switch msg.String() { + case "esc": + m.noteMode = false + m.noteInput.Blur() + m.input.Focus() + m.noteErr = nil + return m, nil + case "ctrl+s": + if !m.noteLoaded { + return m, nil + } + note := m.note + note.Text = m.noteInput.Value() + remote, service := m.noteRemote, m.noteService + m.noteBusy = true + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return noteResultMsg{note: note, saved: true, err: service.Write(ctx, note, remote)} + } + } + var cmd tea.Cmd + m.noteInput, cmd = m.noteInput.Update(msg) + return m, cmd + } // --- Edit mode --- if m.editMode { switch msg.String() { @@ -215,7 +293,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input.Focus() return m, nil case "enter": - if item, ok := m.list.SelectedItem().(hostItem); ok { + if m.editHost != nil { + item := *m.editHost + value := m.editInput.Value() + if idx := strings.LastIndex(value, ":"); idx >= 0 { + port, err := strconv.Atoi(strings.TrimSpace(value[idx+1:])) + if err != nil || port < 1 || port > 65535 { + m.err = fmt.Errorf("port must be between 1 and 65535") + return m, nil + } + } m.selected = m.buildSelected(item) } return m, tea.Quit @@ -261,21 +348,44 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if item, ok := m.list.SelectedItem().(hostItem); ok { - m.selected = &SelectedHost{Name: item.name, IP: item.ip} + m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind} return m, tea.Quit } - case "e": + case "ctrl+e": if item, ok := m.list.SelectedItem().(hostItem); ok { m.openEdit(item) return m, nil } + case "ctrl+n", "ctrl+b": + if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil { + m.noteMode = true + m.noteLoaded = false + m.noteBusy = true + m.noteRemote = msg.String() == "ctrl+b" + m.input.Blur() + m.noteInput.Focus() + m.noteInput.SetValue("") + m.noteErr = nil + host := netbox.HostEntry{ID: item.id, Name: item.name, Kind: item.kind} + m.note = notes.Note{Host: host} + remote, service := m.noteRemote, m.noteService + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + note, err := service.Read(ctx, host, remote) + return noteResultMsg{note: note, err: err} + } + } + return m, nil case "tab": if m.list.Items() != nil && len(m.list.Items()) > 0 { if item, ok := m.list.Items()[0].(hostItem); ok { m.input.SetValue(item.name) m.input.CursorEnd() + m.seq++ + return m, m.startDebounce() } } return m, nil @@ -283,24 +393,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case debounceMsg: q := m.input.Value() - if q == m.lastSent { + if msg.seq != m.seq || msg.query != q { return m, nil } m.lastSent = q m.recentMode = false m.loading = true - m.seq++ seq := m.seq return m, m.doSearch(q, seq) case searchResultMsg: if msg.recent { + if m.input.Value() != "" || m.seq > 0 { + return m, nil + } m.recentMode = true m.lastResults = msg.entries m.updateListItems(msg.entries) return m, nil } - if msg.query != m.lastSent { + if msg.seq != m.seq || msg.query != m.input.Value() { return m, nil // discard stale result } m.loading = false @@ -314,12 +426,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + if m.noteMode { + var cmd tea.Cmd + m.noteInput, cmd = m.noteInput.Update(msg) + return m, cmd + } // Forward to search input and restart debounce timer. var cmds []tea.Cmd var inputCmd tea.Cmd + oldQuery := m.input.Value() m.input, inputCmd = m.input.Update(msg) cmds = append(cmds, inputCmd) - cmds = append(cmds, m.startDebounce()) + if m.input.Value() != oldQuery { + m.seq++ + cmds = append(cmds, m.startDebounce()) + } var listCmd tea.Cmd m.list, listCmd = m.list.Update(msg) @@ -329,6 +450,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Model) View() string { + if m.noteMode { + target := "local" + if m.noteRemote { + target = "NetBox comments" + } + status := "ctrl+s save esc cancel" + if m.noteBusy { + status = "Loading / saving…" + } + errText := "" + if m.noteErr != nil { + errText = "\nError: " + m.noteErr.Error() + } + return fmt.Sprintf("Notes [%s] — %s\n\n%s\n%s%s\n", target, m.note.Host.Name, m.noteInput.View(), status, errText) + } var sb strings.Builder title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh") @@ -358,7 +494,7 @@ func (m *Model) View() string { sb.WriteString(m.editInput.View() + "\n") sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n") } else { - hint := "ctrl+c quit enter connect e edit ctrl+f filter" + hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox" sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n") } @@ -371,8 +507,9 @@ func (m *Model) Selected() *SelectedHost { } func (m *Model) startDebounce() tea.Cmd { + query, seq := m.input.Value(), m.seq return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg { - return debounceMsg{query: m.input.Value()} + return debounceMsg{query: query, seq: seq} }) } @@ -386,20 +523,19 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd { for i, c := range cached { entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags} } - return searchResultMsg{query: query, entries: entries} + return searchResultMsg{query: query, seq: seq, entries: entries} } } if m.client == nil { - return searchResultMsg{query: query, entries: nil} + return searchResultMsg{query: query, seq: seq, entries: nil} } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() entries, err := m.client.Search(ctx, query, opts) - _ = seq - return searchResultMsg{query: query, entries: entries, err: err} + return searchResultMsg{query: query, seq: seq, entries: entries, err: err} } } @@ -423,7 +559,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) { if ip == "" { ip = e.PrimaryIP6 } - items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags} + items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags} } m.list.SetItems(items) } @@ -431,13 +567,15 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) { // openEdit switches to edit mode for the given list item. func (m *Model) openEdit(item hostItem) { m.editMode = true + m.editHost = &item m.input.Blur() user := m.defaultUser - if user == "" { - user = item.name + prefix := "" + if user != "" { + prefix = user + "@" } - m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name)) + m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort)) m.editInput.CursorEnd() m.editInput.Focus() } @@ -445,7 +583,7 @@ func (m *Model) openEdit(item hostItem) { // buildSelected parses the editInput to extract user/port overrides. func (m *Model) buildSelected(item hostItem) *SelectedHost { s := strings.TrimSpace(m.editInput.Value()) - sel := &SelectedHost{Name: item.name, IP: item.ip} + sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind} // Extract user: everything before @ if idx := strings.Index(s, "@"); idx != -1 { @@ -456,10 +594,12 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost { // Extract port: after the last colon, if it's a number if idx := strings.LastIndex(s, ":"); idx != -1 { portStr := strings.TrimSpace(s[idx+1:]) - if _, err := strconv.Atoi(portStr); err == nil && portStr != "22" { + if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 { sel.Port = portStr } } return sel } + +func (m *Model) SetNotes(service *notes.Service) { m.noteService = service } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..03c291f --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes" + tea "github.com/charmbracelet/bubbletea" + "testing" +) + +func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) { + m := New(nil, nil, "") + m.updateListItems([]netbox.HostEntry{{Name: "server"}}) + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + if m.editMode || m.input.Value() != "e" { + t.Fatal("e intercepted") + } +} + +func TestStaleResultsAndDebounceAreIgnored(t *testing.T) { + m := New(nil, nil, "") + m.input.SetValue("web") + m.seq = 3 + m.Update(searchResultMsg{query: "web", seq: 1, entries: []netbox.HostEntry{{Name: "old"}}}) + if len(m.list.Items()) != 0 { + t.Fatal("stale result accepted") + } + _, cmd := m.Update(debounceMsg{query: "w", seq: 1}) + if cmd != nil { + t.Fatal("stale debounce accepted") + } + m.Update(searchResultMsg{query: "web", seq: 3, entries: []netbox.HostEntry{{Name: "web01"}}}) + if len(m.list.Items()) != 1 { + t.Fatal("current result ignored") + } +} + +func TestEditPreservesDefaultAndExplicitPort22(t *testing.T) { + m := New(nil, nil, "", 2222) + item := hostItem{name: "web01", ip: "10.0.0.1"} + m.openEdit(item) + if m.editInput.Value() != "web01:2222" { + t.Fatal(m.editInput.Value()) + } + m.editInput.SetValue("ops@web01:22") + selected := m.buildSelected(item) + if selected.Port != "22" || selected.User != "ops" { + t.Fatalf("bad override: %+v", selected) + } +} + +func TestFailedNoteLoadCannotBeSaved(t *testing.T) { + m := New(nil, nil, "") + m.SetNotes(¬es.Service{Dir: t.TempDir()}) + m.noteMode = true + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + if cmd != nil { + t.Fatal("save allowed before successful load") + } +} + +func TestConnectionEditorKeepsOriginalHost(t *testing.T) { + m := New(nil, nil, "admin") + first := hostItem{name: "first", ip: "10.0.0.1"} + m.openEdit(first) + m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}}) + m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if m.selected == nil || m.selected.Name != "first" { + t.Fatalf("edited host changed: %+v", m.selected) + } +}