Compare commits

..
Author SHA1 Message Date
Sebastian UnterschützandClaude Fable 5.2 4287b7b650 fix: harden config loading and ssh addresses, stop resolver on API errors
- read netssh.yaml from the user config directory only, not from the
  working directory
- refuse addresses from NetBox or the cache that are neither an IP
  address nor a hostname (ssh read "-oProxyCommand=…" as an option)
- resolver chain stops at an API failure; only "no address" falls
  through to the next strategy
- netssh --help and -h show netssh's help
- start at most one background cache refresh per 5 minutes
- validate the legacy netbox.url
- page through all IP addresses of a host
- CI: run format check, vet, and tests on pull requests and main
- changelog for v0.0.25

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-15 23:25:28 +01:00
Sebi 6e80b733dc Merge pull request 'v0.0.24' (#40) from release/v0.0.24 into main
Release / release (push) Successful in 6m20s
2026-11-14 12:56:51 +00:00
Sebastian UnterschützandClaude Opus 5.5 d3e7f137c3 docs: changelog for v0.0.24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 13:56:13 +01:00
Sebi 6493e7c41e Merge pull request 'netssh multi: mehrere Server in einem Fenster (tmux/zellij)' (#37) from feat/multi into main 2026-11-14 12:36:50 +00:00
Sebi a8bf2b4141 Merge pull request 'Docker-Testumgebung + 3 Bugfixes' (#36) from test/docker-env into main 2026-11-14 12:35:36 +00:00
19 changed files with 311 additions and 24 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
name: Test
on:
pull_request:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "gofmt needed for:"
echo "$unformatted"
exit 1
fi
- name: Vet
run: go vet ./...
- name: Run tests
run: go test -race ./...
+12 -1
View File
@@ -1,6 +1,17 @@
# Changelog # Changelog
## Unreleased ## v0.0.25
- Security: `netssh.yaml` is read from the user config directory only. A file of that name in the working directory (e.g. of a cloned repository) was loaded when no user config existed, and could set ssh options such as `ProxyCommand` or a NetBox URL.
- Security: an address from NetBox or the cache that is neither an IP address nor a hostname is refused. A value such as `-oProxyCommand=…` was passed to ssh as an option.
- The resolver chain stops at an API failure instead of trying the next strategy. Before, a NetBox error in `management_subnet` or `interface_name` connected to the primary IP and cached it. A strategy that finds no address (including a missing interface) still falls through. Note: a token without permission to read IP addresses now gives an error instead of silently using `primary_ip`.
- `netssh --help` and `netssh -h` show netssh's help instead of `unknown option --help` and ssh's usage.
- Background cache refreshes are started at most every 5 minutes. While NetBox was unreachable, every command and every new shell started another one.
- `netbox.url` is validated like the URLs of named instances (HTTP or HTTPS, no query or fragment).
- Hosts with more than 100 IP addresses: all pages are read, so `management_subnet` no longer misses addresses.
- CI: tests, `go vet`, and a format check run on pull requests and pushes to `main`.
## v0.0.24
- Fix the `interface_name` resolver strategy: it sent filter names that NetBox ignores and therefore returned the host's first IP address instead of the named interface's. It now uses NetBox's `interface` and `vminterface` filters; a missing interface falls through to the next strategy. - Fix the `interface_name` resolver strategy: it sent filter names that NetBox ignores and therefore returned the host's first IP address instead of the named interface's. It now uses NetBox's `interface` and `vminterface` filters; a missing interface falls through to the next strategy.
- Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths. - Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths.
+1 -1
View File
@@ -200,7 +200,7 @@ shortcuts:
Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.; see [Environment variables](#environment-variables)). Single-instance and shared settings can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.; see [Environment variables](#environment-variables)).
netssh reads `netssh.yaml` from the user config directory and, if none exists there, from the current working directory. Unknown keys are ignored; invalid values (an unknown strategy, a bad regex, an invalid SSH option) stop netssh with an error that names the key. netssh reads `netssh.yaml` from the user config directory only; a file of that name in the working directory is ignored. Unknown keys are ignored; invalid values (an unknown strategy, a bad regex, an invalid SSH option) stop netssh with an error that names the key.
### Configuration reference ### Configuration reference
+19 -1
View File
@@ -62,6 +62,16 @@ func main() {
return return
} }
// ssh has neither option, so both can only mean netssh's own help.
if args[0] == "--help" || args[0] == "-h" {
root := rootCmd()
root.SetArgs([]string{"help"})
if err := root.Execute(); err != nil {
os.Exit(1)
}
return
}
if managedSubcommands[args[0]] { if managedSubcommands[args[0]] {
if err := rootCmd().Execute(); err != nil { if err := rootCmd().Execute(); err != nil {
os.Exit(1) os.Exit(1)
@@ -320,6 +330,9 @@ func findSSHHost(ctx context.Context, client *netbox.Client, name string, domain
// connectHost applies the SSH options configured for the host and execs the client. // connectHost applies the SSH options configured for the host and execs the client.
func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) { func connectHost(cfg *config.Config, client internalssh.Client, flags internalssh.WrapperFlags, host resolvedHost, dests []int, args []string) {
if !internalssh.ValidAddress(host.IP) {
fatalf("refusing address %q for %s: not an IP address or hostname (check NetBox, or run netssh cache clear)", host.IP, host.Name)
}
opts := hostOptions(cfg, host) opts := hostOptions(cfg, host)
if len(flags.Forwards) > 0 { if len(flags.Forwards) > 0 {
forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards) forwardArgs, err := internalssh.ForwardArgs(flags.Forwards, opts.Forwards)
@@ -1000,6 +1013,9 @@ func hookUninstallCmd() *cobra.Command {
return cmd return cmd
} }
// refreshRetryInterval is the pause between background refresh attempts.
const refreshRetryInterval = 5 * time.Minute
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache // maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
// has not been fully refreshed within the last 24 hours. // has not been fully refreshed within the last 24 hours.
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) { func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
@@ -1007,7 +1023,9 @@ func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" { if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" {
return return
} }
if !c.NeedsRefresh(24 * time.Hour) { // While NetBox is unreachable the cache stays stale; without the retry
// interval every command and every new shell would start another refresh.
if !c.ClaimRefresh(24*time.Hour, refreshRetryInterval) {
return return
} }
self, err := os.Executable() self, err := os.Executable()
+1
View File
@@ -71,6 +71,7 @@ func buildUninstallPlan(cfg *config.Config, keepConfig bool) uninstallPlan {
addPath(path) addPath(path)
addPath(path + ".lock") addPath(path + ".lock")
addPath(filepath.Join(filepath.Dir(path), "last_refresh")) addPath(filepath.Join(filepath.Dir(path), "last_refresh"))
addPath(filepath.Join(filepath.Dir(path), "refresh_attempt"))
if strings.HasPrefix(filepath.Base(filepath.Dir(path)), "instances-") { if strings.HasPrefix(filepath.Base(filepath.Dir(path)), "instances-") {
addPath(filepath.Dir(path)) addPath(filepath.Dir(path))
} }
+20
View File
@@ -83,6 +83,26 @@ func (c *Cache) NeedsRefresh(d time.Duration) bool {
return time.Since(t) >= d return time.Since(t) >= d
} }
// ClaimRefresh reports whether a background refresh should start now: the last
// full refresh is older than d and no attempt was claimed within retry. It
// records the attempt, so parallel shells and repeated commands start one
// refresh per retry interval instead of one each.
func (c *Cache) ClaimRefresh(d, retry time.Duration) bool {
if !c.NeedsRefresh(d) {
return false
}
attempt := filepath.Join(filepath.Dir(c.refreshStamp), "refresh_attempt")
if info, err := os.Stat(attempt); err == nil && time.Since(info.ModTime()) < retry {
return false
}
if err := os.MkdirAll(filepath.Dir(attempt), 0o755); err != nil {
return true // cannot record the attempt; refreshing is still right
}
// Best effort: two processes racing here may both refresh once.
_ = os.WriteFile(attempt, nil, 0o600)
return true
}
// SetRefreshed records the current time as the last successful full refresh. // SetRefreshed records the current time as the last successful full refresh.
func (c *Cache) SetRefreshed() error { func (c *Cache) SetRefreshed() error {
if c.refreshStamp == "" { if c.refreshStamp == "" {
+20
View File
@@ -557,3 +557,23 @@ func TestStaticHosts(t *testing.T) {
t.Fatalf("removed static host still on disk:\n%s", data) t.Fatalf("removed static host still on disk:\n%s", data)
} }
} }
func TestClaimRefreshThrottlesAttempts(t *testing.T) {
c := New(filepath.Join(t.TempDir(), "netssh", "hosts.json"), 60)
if !c.ClaimRefresh(24*time.Hour, time.Minute) {
t.Fatal("a never refreshed cache must claim a refresh")
}
// NetBox is down: the stamp stays stale, but the attempt is remembered.
if c.ClaimRefresh(24*time.Hour, time.Minute) {
t.Error("a second refresh within the retry interval must not start")
}
if !c.ClaimRefresh(24*time.Hour, 0) {
t.Error("after the retry interval a refresh must start again")
}
if err := c.SetRefreshed(); err != nil {
t.Fatal(err)
}
if c.ClaimRefresh(24*time.Hour, 0) {
t.Error("a freshly refreshed cache must not claim a refresh")
}
}
+3 -2
View File
@@ -113,8 +113,9 @@ func Load() (*Config, error) {
if err == nil { if err == nil {
v.SetConfigName("netssh") v.SetConfigName("netssh")
v.SetConfigType("yaml") v.SetConfigType("yaml")
v.AddConfigPath(filepath.Join(configDir)) // Only the user's own directory: a netssh.yaml in the working directory
v.AddConfigPath(".") // (e.g. of a cloned repository) could inject ssh options and a NetBox URL.
v.AddConfigPath(configDir)
} }
v.SetEnvPrefix("NETSSH") v.SetEnvPrefix("NETSSH")
+24
View File
@@ -196,3 +196,27 @@ func TestLoad_InvalidShortcutRewrite(t *testing.T) {
} }
} }
} }
// A netssh.yaml in the working directory (e.g. of a cloned repository) must
// not be loaded: it could inject ssh options such as ProxyCommand.
func TestLoad_IgnoresConfigInWorkingDirectory(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("NETSSH_NETBOX_URL", "")
t.Chdir(writeConfig(t, "hosts:\n - name: evil\n address: 10.0.0.1\nssh:\n options:\n ProxyCommand: touch /tmp/pwned\n"))
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Hosts) != 0 || len(cfg.SSH.Options) != 0 {
t.Errorf("config from the working directory was loaded: %+v", cfg)
}
}
func TestLoad_RejectsInvalidLegacyNetBoxURL(t *testing.T) {
for _, bad := range []string{"netbox.example.com", "ftp://netbox.example.com", "https://netbox.example.com/?x=1"} {
t.Setenv("XDG_CONFIG_HOME", writeConfig(t, "netbox:\n url: \""+bad+"\"\n token: t\n"))
if _, err := Load(); err == nil || !strings.Contains(err.Error(), "netbox.url") {
t.Errorf("%s: got %v, want a netbox.url error", bad, err)
}
}
}
+10 -2
View File
@@ -17,6 +17,9 @@ func (c *Config) ValidateNetBoxes() error {
if len(c.NetBoxes) > 0 && c.NetBox.URL != "" { if len(c.NetBoxes) > 0 && c.NetBox.URL != "" {
return fmt.Errorf("configure either netbox or netboxes, not both") return fmt.Errorf("configure either netbox or netboxes, not both")
} }
if c.NetBox.URL != "" && !validNetBoxURL(c.NetBox.URL) {
return fmt.Errorf("netbox.url %q must be an HTTP(S) URL without query or fragment", c.NetBox.URL)
}
seen := make(map[string]bool) seen := make(map[string]bool)
for _, source := range c.NetBoxes { for _, source := range c.NetBoxes {
if !instanceName.MatchString(source.Name) { if !instanceName.MatchString(source.Name) {
@@ -27,8 +30,7 @@ func (c *Config) ValidateNetBoxes() error {
return fmt.Errorf("duplicate NetBox instance name %q", source.Name) return fmt.Errorf("duplicate NetBox instance name %q", source.Name)
} }
seen[name] = true seen[name] = true
u, err := url.Parse(source.URL) if !validNetBoxURL(source.URL) {
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("NetBox %q requires an HTTP(S) URL without query or fragment", source.Name) return fmt.Errorf("NetBox %q requires an HTTP(S) URL without query or fragment", source.Name)
} }
if source.TokenVersion < 0 || source.TokenVersion > 2 { if source.TokenVersion < 0 || source.TokenVersion > 2 {
@@ -38,6 +40,12 @@ func (c *Config) ValidateNetBoxes() error {
return nil return nil
} }
// validNetBoxURL accepts base URLs that API paths can be appended to.
func validNetBoxURL(raw string) bool {
u, err := url.Parse(raw)
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.RawQuery == "" && u.Fragment == ""
}
func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 } func (c *Config) HasNetBox() bool { return c.NetBox.URL != "" || len(c.NetBoxes) > 0 }
// HasHosts reports whether netssh has anything to connect to: NetBox or static hosts. // HasHosts reports whether netssh has anything to connect to: NetBox or static hosts.
+26 -17
View File
@@ -176,25 +176,33 @@ func (c *Client) GetIPs(ctx context.Context, entry HostEntry) ([]string, error)
} }
return source.GetIPs(ctx, host) return source.GetIPs(ctx, host)
} }
var apiURL string
switch entry.Kind { switch entry.Kind {
case "device": case "device":
apiURL = fmt.Sprintf("%s/api/ipam/ip-addresses/?device_id=%d&limit=100", c.baseURL, entry.ID) return c.fetchAllIPs(ctx, fmt.Sprintf("device_id=%d", entry.ID))
case "vm": case "vm":
apiURL = fmt.Sprintf("%s/api/ipam/ip-addresses/?virtual_machine_id=%d&limit=100", c.baseURL, entry.ID) return c.fetchAllIPs(ctx, fmt.Sprintf("virtual_machine_id=%d", entry.ID))
default: }
return nil, fmt.Errorf("unknown host kind: %q", entry.Kind) return nil, fmt.Errorf("unknown host kind: %q", entry.Kind)
} }
// fetchAllIPs pages through the IP addresses matching filterParams, so hosts
// with more addresses than one page do not lose their management address.
func (c *Client) fetchAllIPs(ctx context.Context, filterParams string) ([]string, error) {
const pageSize = 100
var ips []string
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=%d&offset=%d", c.baseURL, filterParams, pageSize, offset)
var resp netboxIPListResponse var resp netboxIPListResponse
if err := c.get(ctx, apiURL, &resp); err != nil { if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err return nil, err
} }
ips := make([]string, 0, len(resp.Results))
for _, r := range resp.Results { for _, r := range resp.Results {
ips = append(ips, stripPrefix(r.Address)) ips = append(ips, stripPrefix(r.Address))
} }
if len(resp.Results) < pageSize || len(ips) >= resp.Count {
break
}
}
return ips, nil return ips, nil
} }
@@ -203,16 +211,7 @@ func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]s
if c.sources != nil { if c.sources != nil {
return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost") return nil, fmt.Errorf("IP filters require a host instance; use GetIPsWithFilterForHost")
} }
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=100", c.baseURL, filterParams) return c.fetchAllIPs(ctx, filterParams)
var resp netboxIPListResponse
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
ips := make([]string, 0, len(resp.Results))
for _, r := range resp.Results {
ips = append(ips, stripPrefix(r.Address))
}
return ips, nil
} }
func (c *Client) searchDevices(ctx context.Context, query, tag string) ([]HostEntry, error) { func (c *Client) searchDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
@@ -348,7 +347,7 @@ func (c *Client) request(ctx context.Context, method, apiURL string, body io.Rea
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL) return "", &StatusError{Code: resp.StatusCode, URL: apiURL}
} }
if err := json.NewDecoder(resp.Body).Decode(out); err != nil { if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
@@ -399,6 +398,16 @@ func stripPrefix(cidr string) string {
return cidr return cidr
} }
// StatusError is an unexpected HTTP status from NetBox.
type StatusError struct {
Code int
URL string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("netbox returned %d for %s", e.Code, e.URL)
}
// ErrHostNotFound distinguishes a missing exact match from API failures or ambiguity. // ErrHostNotFound distinguishes a missing exact match from API failures or ambiguity.
var ErrHostNotFound = errors.New("host not found") var ErrHostNotFound = errors.New("host not found")
+45
View File
@@ -3,8 +3,11 @@ package netbox
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -499,3 +502,45 @@ func TestCommentConflictIsReported(t *testing.T) {
t.Fatal("conflict ignored") t.Fatal("conflict ignored")
} }
} }
// Hosts with more addresses than one page must not lose the later ones.
func TestGetIPs_Paginates(t *testing.T) {
const total = 230
var offsets []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
offsets = append(offsets, r.URL.Query().Get("offset"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
var addrs []string
for i := offset; i < offset+limit && i < total; i++ {
addrs = append(addrs, fmt.Sprintf("10.0.%d.%d/24", i/250, i%250+1))
}
resp := ipListResponse(addrs...)
resp.Count = total
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
ips, err := NewClient(srv.URL, "token", 0).GetIPs(context.Background(), HostEntry{ID: 1, Kind: "device"})
if err != nil {
t.Fatalf("GetIPs: %v", err)
}
if len(ips) != total || ips[total-1] != "10.0.0.230" {
t.Errorf("got %d IPs (last %q), want %d", len(ips), ips[len(ips)-1], total)
}
if strings.Join(offsets, ",") != "0,100,200" {
t.Errorf("offsets %v, want 0,100,200", offsets)
}
}
func TestRequest_StatusErrorCarriesCode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
_, err := NewClient(srv.URL, "token", 0).GetIPsWithFilter(context.Background(), "device_id=1&interface=nope")
var status *StatusError
if !errors.As(err, &status) || status.Code != http.StatusBadRequest {
t.Fatalf("got %v, want a StatusError with code 400", err)
}
}
+7
View File
@@ -2,6 +2,7 @@ package resolver
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
@@ -32,6 +33,12 @@ func (c *Chain) Resolve(ctx context.Context, entry *netbox.HostEntry, client *ne
if err == nil { if err == nil {
return ip, nil return ip, nil
} }
// Only "this strategy has no address" moves on. After an API failure
// the next strategy would connect to (and cache) a different address
// than the configured order asks for.
if !errors.Is(err, ErrNoIP) {
return "", fmt.Errorf("resolver strategy %q for %q: %w", s.Name(), entry.Name, err)
}
} }
return "", fmt.Errorf("no strategy resolved an IP for %q", entry.Name) return "", fmt.Errorf("no strategy resolved an IP for %q", entry.Name)
} }
+14
View File
@@ -153,3 +153,17 @@ func TestNew_MultipleStrategies(t *testing.T) {
t.Errorf("got %d strategies, want 2", len(c.strategies)) t.Errorf("got %d strategies, want 2", len(c.strategies))
} }
} }
// An API failure must not fall through: the next strategy would connect to
// and cache a different address than the configured order asks for.
func TestChain_APIErrorStopsTheChain(t *testing.T) {
apiErr := errors.New("connection refused")
c := &Chain{strategies: []Strategy{
&stubStrategy{name: "management_subnet", err: apiErr},
&stubStrategy{name: "primary_ip", ip: "10.0.0.2"},
}}
ip, err := c.Resolve(context.Background(), &netbox.HostEntry{Name: "host"}, nil)
if !errors.Is(err, apiErr) || ip != "" {
t.Fatalf("got %q, %v; want the API error and no address", ip, err)
}
}
+6
View File
@@ -2,7 +2,9 @@ package resolver
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
@@ -31,6 +33,10 @@ func (s *InterfaceNameStrategy) Resolve(ctx context.Context, entry *netbox.HostE
} }
ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam) ips, err := client.GetIPsWithFilterForHost(ctx, *entry, filterParam)
var status *netbox.StatusError
if errors.As(err, &status) && status.Code == http.StatusBadRequest {
return "", ErrNoIP // the host has no interface of this name
}
if err != nil { if err != nil {
return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err) return "", fmt.Errorf("fetching IPs for interface %q: %w", s.name, err)
} }
+14
View File
@@ -2,6 +2,7 @@ package resolver
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -54,3 +55,16 @@ func urlQuery(s string) (string, error) {
q, err := url.ParseQuery(s) q, err := url.ParseQuery(s)
return q.Encode(), err return q.Encode(), err
} }
// Only the HTTP 400 of a missing interface means "no address here"; a server
// error must stop the chain instead of silently using the next strategy.
func TestInterfaceNameStrategyServerErrorIsNotNoIP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer srv.Close()
_, err := (&InterfaceNameStrategy{name: "mgmt0"}).Resolve(context.Background(), &netbox.HostEntry{ID: 7, Kind: "device"}, netbox.NewClient(srv.URL, "t", 1))
if err == nil || errors.Is(err, ErrNoIP) {
t.Fatalf("got %v, want an API error", err)
}
}
+13
View File
@@ -2,6 +2,7 @@ package ssh
import ( import (
"fmt" "fmt"
"net/netip"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -142,6 +143,18 @@ type Result struct {
Notice string Notice string
} }
var destinationName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
// ValidAddress reports whether addr is an IP address or a hostname. Addresses
// come from NetBox and the cache file; one such as "-oProxyCommand=…" would be
// read by ssh as an option and run a command.
func ValidAddress(addr string) bool {
if _, err := netip.ParseAddr(addr); err == nil {
return !strings.ContainsAny(addr, " \t\r\n")
}
return destinationName.MatchString(addr)
}
var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`) var plainHostname = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$`)
// Apply replaces the destination at destIdx and inserts the configured -o // Apply replaces the destination at destIdx and inserts the configured -o
+14
View File
@@ -128,3 +128,17 @@ func TestQuote(t *testing.T) {
t.Fatalf("got %s", got) t.Fatalf("got %s", got)
} }
} }
func TestValidAddress(t *testing.T) {
for _, addr := range []string{"10.0.0.1", "fd00:30::60", "fe80::1%eth0", "web01", "web01.example.com", "db_1.lan"} {
if !ValidAddress(addr) {
t.Errorf("ValidAddress(%q) = false, want true", addr)
}
}
// ssh would read the first as an option and run the command.
for _, addr := range []string{"-oProxyCommand=touch /tmp/pwned", "-J", "", "a b", "host;id", "user@host", "host/path", "fe80::1%eth0 x", "$(id)"} {
if ValidAddress(addr) {
t.Errorf("ValidAddress(%q) = true, want false", addr)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package storage
import (
"os"
"path/filepath"
"testing"
)
func TestWriteFileReplacesAtomically(t *testing.T) {
dir := filepath.Join(t.TempDir(), "new", "dir")
path := filepath.Join(dir, "file")
for _, content := range []string{"first", "second"} {
if err := WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil || string(got) != content {
t.Fatalf("got %q, %v; want %q", got, err, content)
}
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o600 {
t.Errorf("mode %v, %v; want 0600", info.Mode().Perm(), err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 1 {
t.Errorf("temporary files left behind: %v", entries)
}
}