Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4287b7b650 | ||
|
|
6e80b733dc |
No files matched your search
@@ -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 ./...
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+20
@@ -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 == "" {
|
||||||
|
|||||||
Vendored
+20
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
+33
-24
@@ -176,24 +176,32 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
var resp netboxIPListResponse
|
// fetchAllIPs pages through the IP addresses matching filterParams, so hosts
|
||||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
// with more addresses than one page do not lose their management address.
|
||||||
return nil, err
|
func (c *Client) fetchAllIPs(ctx context.Context, filterParams string) ([]string, error) {
|
||||||
}
|
const pageSize = 100
|
||||||
|
var ips []string
|
||||||
ips := make([]string, 0, len(resp.Results))
|
for offset := 0; ; offset += pageSize {
|
||||||
for _, r := range resp.Results {
|
apiURL := fmt.Sprintf("%s/api/ipam/ip-addresses/?%s&limit=%d&offset=%d", c.baseURL, filterParams, pageSize, offset)
|
||||||
ips = append(ips, stripPrefix(r.Address))
|
var resp netboxIPListResponse
|
||||||
|
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range resp.Results {
|
||||||
|
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")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user