diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35eaa2b..e414ee6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: - version: v2.7.2 + version: v2.13.2 args: --timeout 10m - name: Test diff --git a/cmd/app.go b/cmd/app.go index 3d034ad..761070e 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -56,7 +56,7 @@ func newApp(configPath string, logger *slog.Logger) (*app, error) { registry := proxy.NewRegistry() for _, ds := range cfg.Datasources { - configureDatasource(logger, registry, secretsMgr, ds) + configureDatasource(logger, registry, secretsMgr, verifier.Enabled(), ds) } handler := ws.NewHandler(registry, credStore, secretsMgr, verifier, logger) @@ -98,7 +98,7 @@ func (a *app) run(ctx context.Context) error { return a.client.Run(ctx) } -func configureDatasource(logger *slog.Logger, registry *proxy.Registry, secretsMgr *secrets.Manager, ds config.LocalDatasource) { +func configureDatasource(logger *slog.Logger, registry *proxy.Registry, secretsMgr *secrets.Manager, signingEnabled bool, ds config.LocalDatasource) { logger.Info("configuring datasource", "name", ds.Name, "type", ds.Type, "credential_source", ds.CredentialSource) cfg := map[string]any{} @@ -240,6 +240,16 @@ func configureDatasource(logger *slog.Logger, registry *proxy.Registry, secretsM CredentialSource: credSource, } registry.Register(entry.ID, entry, p) + + if proxyType == "discovery-proxy" && proxydiscovery.SSHAccessEnabled(cfg) { + sibling, sp, err := proxydiscovery.SSHAccessSibling(entry, cfg, creds, signingEnabled, logger) + if err != nil { + logger.Error("ssh_access not enabled", "name", ds.Name, "err", err) + return + } + registry.Register(sibling.ID, sibling, sp) + logger.Info("ssh access enabled for discovery scope", "name", ds.Name, "id", sibling.ID) + } } // applyDiscoveryConfig copies the discovery block from local YAML into the @@ -256,6 +266,9 @@ func applyDiscoveryConfig(cfg map[string]any, d *config.DiscoveryDatasource) { setIfNotEmpty(cfg, "pack_public_key", d.PackPublicKey) setIfNotEmpty(cfg, "pack_dir", d.PackDir) setIfNotEmpty(cfg, "known_hosts_file", d.KnownHostsFile) + if d.SSHAccess { + cfg["ssh_access"] = true + } setIfPositive(cfg, "port", d.Port) setIfPositive(cfg, "concurrency", d.Concurrency) diff --git a/go.mod b/go.mod index 48781b1..bfe3f69 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module nudgebee/forager -go 1.25.13 +go 1.26.6 require ( cloud.google.com/go/auth v0.23.1 @@ -25,7 +25,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/xdg-go/scram v1.2.0 go.mongodb.org/mongo-driver/v2 v2.8.0 - golang.org/x/crypto v0.55.0 + golang.org/x/crypto v0.56.0 golang.org/x/sys v0.47.0 golang.org/x/time v0.15.0 google.golang.org/api v0.293.0 @@ -120,6 +120,6 @@ require ( google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea // indirect - google.golang.org/grpc v1.83.0 // indirect + google.golang.org/grpc v1.83.1 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index d4d09a0..d86c542 100644 --- a/go.sum +++ b/go.sum @@ -279,8 +279,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -335,8 +335,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea h1:kVhQEPTpKQahD5+JSBTfBB19wcgQTTjAIn45MBqnyHk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/config/config.go b/pkg/config/config.go index b6641d2..d34aab0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -44,6 +44,12 @@ type DiscoveryDatasource struct { // KnownHostsFile enables SSH host key verification when set. KnownHostsFile string `mapstructure:"known_hosts_file"` + // SSHAccess also exposes the in-scope hosts for ad-hoc commands through + // a sibling ssh-proxy datasource, using the same credentials. Off by + // default: it widens what the credentials were granted for. Requires + // signing_public_key, known_hosts_file and a non-empty allowed_hosts. + SSHAccess bool `mapstructure:"ssh_access"` + Port int `mapstructure:"port"` Concurrency int `mapstructure:"concurrency"` HostTimeoutS int `mapstructure:"host_timeout_seconds"` diff --git a/pkg/proxy/discovery/README.md b/pkg/proxy/discovery/README.md index 199d34e..da0dfd2 100644 --- a/pkg/proxy/discovery/README.md +++ b/pkg/proxy/discovery/README.md @@ -177,12 +177,33 @@ and no target list of its own. | `pack_public_key` | — | Required; without it no pack can be trusted, so nothing runs. | | `pack_dir` | — | Directory holding signed packs, named `linux-inventory-v.yaml`. Required to run inventory. | | `known_hosts_file` | — | OpenSSH known_hosts path. When set, host keys are verified and unknown/changed keys are refused. | +| `ssh_access` | false | Also expose in-scope hosts for ad-hoc commands (see below). | Credentials arrive through `pkg/secrets`, local or cloud-push, and never appear in logs or responses: `username` plus `private_key` or `password` for SSH, and `ldap_bind_dn` / `ldap_bind_password` for the directory. LDAP bind failures echo the DN back, so those errors are redacted before they leave. +## SSH access (`ssh_access`) + +Inventory only runs signed pack commands. Setting `ssh_access: true` also +registers a sibling `ssh-proxy` datasource, `:ssh` named `-ssh`, in +dynamic mode: +- It uses the same credentials. +- `allowed_hosts` is set to `allowed_cidrs`. +- It verifies host keys against the same `known_hosts_file`. + +The server registers the sibling like any other ssh datasource, so +`ssh_command` can target any in-scope host by passing `params.host`. The discovery datasource itself is unchanged. + +The flag widens what the credentials were granted for, from signed packs to +arbitrary commands. For that reason the sibling is refused, and an error is +logged, unless all of the following hold: +- `signing_public_key` is configured, so unsigned commands are rejected. +- `known_hosts_file` is set. The trust-on-first-use tradeoff above is + acceptable for inventory but not for a shell. +- `allowed_cidrs` is non-empty. An unrestricted scope is never exposed. + ## Pack format See `docs/content-packs/linux-inventory-example.yaml` for a documented diff --git a/pkg/proxy/discovery/executor_test.go b/pkg/proxy/discovery/executor_test.go index 3991841..b26f7e9 100644 --- a/pkg/proxy/discovery/executor_test.go +++ b/pkg/proxy/discovery/executor_test.go @@ -156,7 +156,7 @@ func (s *fakeSSHServer) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request) continue } _ = req.Reply(true, nil) - cmd := string(req.Payload[4:]) + cmd := string(req.Payload[4:]) //nolint:staticcheck // SA6001: converted once, used for four lookups done := s.trackExec() commandDelay := s.delay diff --git a/pkg/proxy/discovery/ssh_access.go b/pkg/proxy/discovery/ssh_access.go new file mode 100644 index 0000000..bc76419 --- /dev/null +++ b/pkg/proxy/discovery/ssh_access.go @@ -0,0 +1,110 @@ +package discovery + +import ( + "errors" + "fmt" + "log/slog" + + "nudgebee/forager/pkg/proxy" + sshproxy "nudgebee/forager/pkg/proxy/ssh" +) + +// SSHAccessSuffix is appended to a discovery datasource ID to form the ID of +// its SSH access sibling. +const SSHAccessSuffix = ":ssh" + +// SSHAccessEnabled reports whether a discovery datasource config opts into +// exposing its hosts for ad-hoc commands. +func SSHAccessEnabled(cfg map[string]any) bool { + v, _ := cfg["ssh_access"].(bool) + return v +} + +// SSHAccessConfig derives the ssh-proxy config for the SSH access sibling of +// a discovery datasource. +// +// Discovery credentials are granted for signed content packs. ssh_access lets +// the same credentials run arbitrary commands, so it is held to a stricter bar +// than inventory: the scope must be explicit (an empty allowed_cidrs means +// "anywhere" to discovery, which is not acceptable for a shell), host keys +// must be verified, and requests must be signed. +func SSHAccessConfig(cfg map[string]any, signingEnabled bool) (map[string]any, error) { + if !signingEnabled { + return nil, errors.New("ssh_access requires signing_public_key so commands are verified") + } + + knownHosts, _ := cfg["known_hosts_file"].(string) + if knownHosts == "" { + return nil, errors.New("ssh_access requires known_hosts_file so host keys are verified") + } + + cidrs := stringSlice(cfg["allowed_cidrs"]) + if len(cidrs) == 0 { + return nil, errors.New("ssh_access requires a non-empty allowed_cidrs scope") + } + + out := map[string]any{ + "host": "", // dynamic mode: the target comes with each request + "allowed_hosts": cidrs, + "known_hosts": knownHosts, + } + if port, ok := cfg["port"]; ok { + out["port"] = port + } + if n, ok := cfg["max_output_bytes"]; ok { + out["max_output_bytes"] = n + } + return out, nil +} + +// SSHAccessSibling builds and configures the ssh-proxy that exposes a +// discovery datasource's hosts for ad-hoc commands. The returned entry +// registers it as its own datasource, reported to the server as type ssh +// for it, leaving the discovery datasource's type untouched. +func SSHAccessSibling(discovery proxy.DatasourceEntry, cfg map[string]any, creds map[string]string, signingEnabled bool, logger *slog.Logger) (proxy.DatasourceEntry, proxy.Proxy, error) { + sshCfg, err := SSHAccessConfig(cfg, signingEnabled) + if err != nil { + return proxy.DatasourceEntry{}, nil, err + } + + id := discovery.ID + SSHAccessSuffix + p := sshproxy.New(logger.With("datasource", id)) + if err := p.Configure(sshCfg, creds); err != nil { + return proxy.DatasourceEntry{}, nil, fmt.Errorf("configuring ssh access: %w", err) + } + + entry := proxy.DatasourceEntry{ + ID: id, + Type: "ssh", + ProxyType: p.Type(), + Name: discovery.Name + "-ssh", + CredentialSource: discovery.CredentialSource, + CredentialRef: discovery.CredentialRef, + } + return entry, p, nil +} + +// stringSlice accepts both []string (local config) and []any (JSON-decoded +// cloud push) forms, dropping empty entries from either. +func stringSlice(v any) []string { + switch s := v.(type) { + case []string: + out := make([]string, 0, len(s)) + for _, e := range s { + if e != "" { + out = append(out, e) + } + } + return out + case []any: + out := make([]string, 0, len(s)) + for _, e := range s { + if str, ok := e.(string); ok && str != "" { + out = append(out, str) + } + } + return out + default: + return nil + } +} diff --git a/pkg/proxy/discovery/ssh_access_test.go b/pkg/proxy/discovery/ssh_access_test.go new file mode 100644 index 0000000..8549e9a --- /dev/null +++ b/pkg/proxy/discovery/ssh_access_test.go @@ -0,0 +1,144 @@ +package discovery + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "nudgebee/forager/pkg/proxy" +) + +func knownHostsFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "known_hosts") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatalf("writing known_hosts: %v", err) + } + return path +} + +func TestSSHAccessEnabled(t *testing.T) { + if SSHAccessEnabled(map[string]any{}) { + t.Error("enabled with no flag; ssh_access must be opt-in") + } + if SSHAccessEnabled(map[string]any{"ssh_access": "true"}) { + t.Error("string flag accepted; only a real bool opts in") + } + if !SSHAccessEnabled(map[string]any{"ssh_access": true}) { + t.Error("not enabled with ssh_access: true") + } +} + +// Each guard exists because the sibling turns inventory credentials into a +// shell: without them a misconfiguration silently grants more than intended. +func TestSSHAccessConfigRefusesUnsafeScopes(t *testing.T) { + kh := knownHostsFile(t) + base := func() map[string]any { + return map[string]any{ + "allowed_cidrs": []string{"192.0.2.10/32"}, + "known_hosts_file": kh, + } + } + + cases := []struct { + name string + mutate func(map[string]any) + signing bool + wantErr string + }{ + {"unsigned", func(map[string]any) {}, false, "signing_public_key"}, + {"no known_hosts", func(c map[string]any) { delete(c, "known_hosts_file") }, true, "known_hosts_file"}, + {"unrestricted scope", func(c map[string]any) { delete(c, "allowed_cidrs") }, true, "allowed_cidrs"}, + {"empty scope", func(c map[string]any) { c["allowed_cidrs"] = []any{} }, true, "allowed_cidrs"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := base() + tc.mutate(cfg) + _, err := SSHAccessConfig(cfg, tc.signing) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want mention of %q", err, tc.wantErr) + } + }) + } +} + +func TestSSHAccessConfigIsDynamicAndScoped(t *testing.T) { + kh := knownHostsFile(t) + cfg, err := SSHAccessConfig(map[string]any{ + // JSON-decoded cloud push delivers []any, not []string. + "allowed_cidrs": []any{"192.0.2.10/32", "10.0.0.0/24"}, + "known_hosts_file": kh, + "port": 2222, + }, true) + if err != nil { + t.Fatalf("SSHAccessConfig: %v", err) + } + + if cfg["host"] != "" { + t.Errorf("host = %v, want empty (dynamic mode)", cfg["host"]) + } + hosts, _ := cfg["allowed_hosts"].([]string) + if len(hosts) != 2 || hosts[0] != "192.0.2.10/32" || hosts[1] != "10.0.0.0/24" { + t.Errorf("allowed_hosts = %v, want the discovery scope", cfg["allowed_hosts"]) + } + if cfg["known_hosts"] != kh { + t.Errorf("known_hosts = %v, want %s", cfg["known_hosts"], kh) + } + if cfg["port"] != 2222 { + t.Errorf("port = %v, want 2222", cfg["port"]) + } +} + +func TestSSHAccessSiblingEntry(t *testing.T) { + discovery := proxy.DatasourceEntry{ + ID: "local:segment-a", Type: "discovery", ProxyType: "discovery-proxy", + Name: "segment-a", CredentialSource: "local", + } + cfg := map[string]any{ + "allowed_cidrs": []string{"192.0.2.10/32"}, + "known_hosts_file": knownHostsFile(t), + } + creds := map[string]string{"username": "inventory", "password": "x"} + + entry, p, err := SSHAccessSibling(discovery, cfg, creds, true, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("SSHAccessSibling: %v", err) + } + defer p.Close() // nolint:errcheck + + if entry.ID != "local:segment-a:ssh" || entry.Name != "segment-a-ssh" { + t.Errorf("entry id/name = %q/%q", entry.ID, entry.Name) + } + if entry.Type != "ssh" || entry.ProxyType != "ssh-proxy" { + t.Errorf("entry type = %q/%q, want ssh/ssh-proxy", entry.Type, entry.ProxyType) + } + if entry.CredentialSource != "local" { + t.Errorf("credential source = %q, want inherited", entry.CredentialSource) + } +} + +func TestSSHAccessSiblingNeedsUsername(t *testing.T) { + cfg := map[string]any{ + "allowed_cidrs": []string{"192.0.2.10/32"}, + "known_hosts_file": knownHostsFile(t), + } + _, _, err := SSHAccessSibling(proxy.DatasourceEntry{ID: "d"}, cfg, map[string]string{}, true, slog.New(slog.DiscardHandler)) + if err == nil { + t.Fatal("sibling configured without ssh credentials") + } +} + +func TestSSHAccessConfigDropsEmptyScopeEntries(t *testing.T) { + kh := knownHostsFile(t) + for _, cidrs := range []any{[]string{"", ""}, []any{"", nil}} { + if _, err := SSHAccessConfig(map[string]any{"allowed_cidrs": cidrs, "known_hosts_file": kh}, true); err == nil { + t.Errorf("scope %v of only empty entries accepted", cidrs) + } + } + if SSHAccessEnabled(nil) { + t.Error("nil config enabled ssh_access") + } +} diff --git a/pkg/proxy/ssh/proxy.go b/pkg/proxy/ssh/proxy.go index 26bfdd8..5179c24 100644 --- a/pkg/proxy/ssh/proxy.go +++ b/pkg/proxy/ssh/proxy.go @@ -209,7 +209,18 @@ func (p *Proxy) parseAllowedHosts(hosts []string) error { p.allowedNets = append(p.allowedNets, ipNet) continue } - // Not a CIDR — treat as hostname or IP + // A bare address is a single-host network, as in discovery's + // allowed_cidrs. Keeping it as a string would only match a request + // using that exact spelling, never a hostname resolving to it. + if ip := net.ParseIP(h); ip != nil { + // Store v4 in its 4-byte form so IP and mask lengths agree. + if ip4 := ip.To4(); ip4 != nil { + p.allowedNets = append(p.allowedNets, &net.IPNet{IP: ip4, Mask: net.CIDRMask(32, 32)}) + } else { + p.allowedNets = append(p.allowedNets, &net.IPNet{IP: ip, Mask: net.CIDRMask(128, 128)}) + } + continue + } p.allowedHosts = append(p.allowedHosts, h) } return nil diff --git a/pkg/proxy/ssh/proxy_test.go b/pkg/proxy/ssh/proxy_test.go index 4886fff..3206584 100644 --- a/pkg/proxy/ssh/proxy_test.go +++ b/pkg/proxy/ssh/proxy_test.go @@ -413,6 +413,34 @@ func TestIsHostAllowed(t *testing.T) { } } +// A bare IP in allowed_hosts is a single-host network, so equivalent +// spellings of the address match, and an address outside it does not. +func TestParseAllowedHosts_BareIPIsSingleHostNet(t *testing.T) { + p := New(testLogger()) + if err := p.parseAllowedHosts([]string{"192.0.2.10", "2001:db8::1", "special-host.local"}); err != nil { + t.Fatalf("parseAllowedHosts: %v", err) + } + if len(p.allowedNets) != 2 || len(p.allowedHosts) != 1 { + t.Fatalf("nets=%v hosts=%v, want 2 nets and only the hostname kept as a string", p.allowedNets, p.allowedHosts) + } + + tests := []struct { + host string + allowed bool + }{ + {"192.0.2.10", true}, + {"192.0.2.11", false}, + {"2001:db8:0:0:0:0:0:1", true}, // same address, non-canonical spelling + {"2001:db8::2", false}, + {"special-host.local", true}, + } + for _, tt := range tests { + if got := p.isHostAllowed(tt.host); got != tt.allowed { + t.Errorf("isHostAllowed(%s) = %v, want %v", tt.host, got, tt.allowed) + } + } +} + func TestEvictOldest(t *testing.T) { p := New(testLogger()) p.pool = map[string]*poolEntry{ diff --git a/pkg/secrets/gcp_sm.go b/pkg/secrets/gcp_sm.go index 5abd90e..2ac9178 100644 --- a/pkg/secrets/gcp_sm.go +++ b/pkg/secrets/gcp_sm.go @@ -5,8 +5,10 @@ import ( "encoding/json" "fmt" "log/slog" + "os" "sync" + "cloud.google.com/go/auth" "cloud.google.com/go/auth/credentials" secretmanager "cloud.google.com/go/secretmanager/apiv1" "cloud.google.com/go/secretmanager/apiv1/secretmanagerpb" @@ -59,10 +61,7 @@ func (g *GCPSM) ensureClient(ctx context.Context) error { g.initOnce.Do(func() { var opts []option.ClientOption if g.credentialsFile != "" { - creds, err := credentials.DetectDefault(&credentials.DetectOptions{ - CredentialsFile: g.credentialsFile, - Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, - }) + creds, err := loadCredentialsFile(g.credentialsFile) if err != nil { g.initErr = fmt.Errorf("gcp_sm: load credentials from %s: %w", g.credentialsFile, err) return @@ -78,3 +77,27 @@ func (g *GCPSM) ensureClient(ctx context.Context) error { }) return g.initErr } + +// loadCredentialsFile loads the operator-configured credentials file. +// DetectOptions.CredentialsFile is deprecated because it accepts any +// credential type implicitly; the loader now takes the type explicitly, so +// read it from the file. The path comes from forager's own config, not from +// a request, so every type the file may declare is trusted as before. +func loadCredentialsFile(path string) (*auth.Credentials, error) { + b, err := os.ReadFile(path) // #nosec G304 -- operator-configured path + if err != nil { + return nil, err + } + var head struct { + Type string `json:"type"` + } + if err := json.Unmarshal(b, &head); err != nil { + return nil, fmt.Errorf("parsing credentials file: %w", err) + } + if head.Type == "" { + return nil, fmt.Errorf("credentials file has no \"type\" field") + } + return credentials.NewCredentialsFromJSON(credentials.CredType(head.Type), b, &credentials.DetectOptions{ + Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"}, + }) +} diff --git a/pkg/secrets/gcp_sm_creds_test.go b/pkg/secrets/gcp_sm_creds_test.go new file mode 100644 index 0000000..7d7b247 --- /dev/null +++ b/pkg/secrets/gcp_sm_creds_test.go @@ -0,0 +1,24 @@ +package secrets + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadCredentialsFile_RequiresType(t *testing.T) { + path := filepath.Join(t.TempDir(), "creds.json") + if err := os.WriteFile(path, []byte(`{"client_email":"x"}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadCredentialsFile(path); err == nil || !strings.Contains(err.Error(), "type") { + t.Fatalf("err = %v, want missing type error", err) + } +} + +func TestLoadCredentialsFile_MissingFile(t *testing.T) { + if _, err := loadCredentialsFile(filepath.Join(t.TempDir(), "nope.json")); err == nil { + t.Fatal("expected error for missing file") + } +} diff --git a/pkg/signing/verify.go b/pkg/signing/verify.go index dfc3fd2..b5d11bd 100644 --- a/pkg/signing/verify.go +++ b/pkg/signing/verify.go @@ -120,8 +120,9 @@ func ParsePublicKey(s string) (ed25519.PublicKey, error) { } // Enabled returns true if signature verification is active. +// Enabled is nil-safe: a nil verifier verifies nothing. func (v *Verifier) Enabled() bool { - return v.enabled + return v != nil && v.enabled } // Verify checks the Ed25519 signature on a message. diff --git a/pkg/signing/verify_test.go b/pkg/signing/verify_test.go index 46eec59..5cce7c5 100644 --- a/pkg/signing/verify_test.go +++ b/pkg/signing/verify_test.go @@ -446,3 +446,10 @@ func TestVerify_ConcurrentReplay(t *testing.T) { t.Fatalf("expected %d replay rejections, got %d", goroutines-1, replayCount) } } + +func TestNilVerifierIsDisabled(t *testing.T) { + var v *Verifier + if v.Enabled() { + t.Fatal("nil verifier reported enabled") + } +} diff --git a/pkg/ws/client.go b/pkg/ws/client.go index 74832fc..a80735f 100644 --- a/pkg/ws/client.go +++ b/pkg/ws/client.go @@ -177,13 +177,14 @@ func (c *Client) connectAndServe(ctx context.Context) error { "agent_build_time": version.BuildTime, "protocol_version": "1.0.0", "capabilities": map[string]bool{ - "http-proxy": true, - "db-proxy": true, - "mcp-proxy": true, - "ssh-proxy": true, - "mongo-proxy": true, - "redis-proxy": true, - "kafka-proxy": true, + "http-proxy": true, + "db-proxy": true, + "mcp-proxy": true, + "ssh-proxy": true, + "mongo-proxy": true, + "redis-proxy": true, + "kafka-proxy": true, + "discovery-proxy": true, }, } greetingBytes, _ := json.Marshal(greeting) diff --git a/pkg/ws/handler.go b/pkg/ws/handler.go index ba6d622..0512de1 100644 --- a/pkg/ws/handler.go +++ b/pkg/ws/handler.go @@ -10,6 +10,7 @@ import ( "nudgebee/forager/pkg/proxy" dbproxy "nudgebee/forager/pkg/proxy/db" + discoveryproxy "nudgebee/forager/pkg/proxy/discovery" httpproxy "nudgebee/forager/pkg/proxy/http" kafkaproxy "nudgebee/forager/pkg/proxy/kafka" mcpproxy "nudgebee/forager/pkg/proxy/mcp" @@ -276,7 +277,8 @@ func normalizeConfigValues(config map[string]any) { } // newProxyByType creates a proxy instance for the given proxy type. -// Handles db_type fallback for db-proxy and allowed_hosts injection for ssh-proxy. +// Handles db_type fallback for db-proxy and allowed_hosts injection for +// ssh-proxy (as allowed_hosts) and discovery-proxy (as allowed_cidrs). func newProxyByType(proxyType, dsType string, config map[string]any, allowedHosts []string, logger *slog.Logger) (proxy.Proxy, error) { switch proxyType { case "http-proxy": @@ -300,6 +302,13 @@ func newProxyByType(proxyType, dsType string, config map[string]any, allowedHost return redisproxy.New(logger), nil case "kafka-proxy": return kafkaproxy.New(logger), nil + case "discovery-proxy": + if len(allowedHosts) > 0 { + if _, set := config["allowed_cidrs"]; !set { + config["allowed_cidrs"] = allowedHosts + } + } + return discoveryproxy.New(logger), nil default: return nil, fmt.Errorf("unknown proxy type: %s", proxyType) } @@ -360,6 +369,12 @@ func (h *Handler) handleConfigSync(ctx context.Context, msg []byte, requestID st creds = resolved } + // A push without "config" decodes to a nil map, which newProxyByType + // writes allowed_hosts/allowed_cidrs into. + if ds.Config == nil { + ds.Config = map[string]any{} + } + // Coerce string config values (e.g. "true"/"5432") to native types normalizeConfigValues(ds.Config) @@ -385,6 +400,18 @@ func (h *Handler) handleConfigSync(ctx context.Context, msg []byte, requestID st } h.registry.Register(ds.ID, entry, p) h.logger.Info("datasource configured", "id", ds.ID, "type", ds.Type, "proxy_type", ds.ProxyType) + + if ds.ProxyType == "discovery-proxy" && discoveryproxy.SSHAccessEnabled(ds.Config) { + sibling, sp, err := discoveryproxy.SSHAccessSibling(entry, ds.Config, creds, h.verifier.Enabled(), h.logger) + if err != nil { + // Left out of newIDs, so a previously registered sibling is removed below. + h.logger.Error("ssh_access not enabled", "datasource_id", ds.ID, "err", err) + continue + } + newIDs[sibling.ID] = true + h.registry.Register(sibling.ID, sibling, sp) + h.logger.Info("ssh access enabled for discovery scope", "datasource_id", ds.ID, "id", sibling.ID) + } } // Remove cloud-managed datasources not in the new config. @@ -443,6 +470,9 @@ func (h *Handler) handleTestDatasourceConfig(ctx context.Context, msg []byte, re } // Coerce string config values (e.g. "true"/"5432") to native types + if ds.Config == nil { + ds.Config = map[string]any{} + } normalizeConfigValues(ds.Config) // Create temporary proxy diff --git a/pkg/ws/ssh_access_test.go b/pkg/ws/ssh_access_test.go new file mode 100644 index 0000000..6d663e3 --- /dev/null +++ b/pkg/ws/ssh_access_test.go @@ -0,0 +1,123 @@ +package ws + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "log/slog" + "os" + "path/filepath" + "testing" + + "nudgebee/forager/pkg/proxy" + "nudgebee/forager/pkg/secrets" + "nudgebee/forager/pkg/signing" +) + +func discoverySyncMsg(t *testing.T, sshAccess bool, knownHosts string, priv ed25519.PrivateKey) []byte { + t.Helper() + return signLikeRelay(t, map[string]any{ + "action": "datasource_config_sync", + "account_id": "acc-1", + "datasources": []any{map[string]any{ + "id": "ds-disc", + "type": "discovery", + "proxy_type": "discovery-proxy", + "name": "segment-a", + "config": map[string]any{ + "ssh_access": sshAccess, + "known_hosts_file": knownHosts, + }, + "allowed_hosts": []any{"192.0.2.10/32"}, + "credentials": map[string]any{"username": "inventory", "password": "x"}, + "credential_source": "cloud_push", + }}, + }, priv) +} + +func signedHandler(t *testing.T) (*Handler, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generating key: %v", err) + } + logger := slog.New(slog.DiscardHandler) + verifier, err := signing.NewVerifier(base64.StdEncoding.EncodeToString(pub), logger) + if err != nil { + t.Fatalf("verifier: %v", err) + } + store, err := secrets.NewCloudPushStore(t.TempDir(), "test-secret") + if err != nil { + t.Fatalf("cred store: %v", err) + } + return NewHandler(proxy.NewRegistry(), store, secrets.NewManager(logger), verifier, logger), priv +} + +// A cloud-pushed discovery datasource with ssh_access registers both the +// discovery proxy and an ssh sibling; turning the flag off removes the sibling. +func TestConfigSync_DiscoverySSHAccessSibling(t *testing.T) { + kh := filepath.Join(t.TempDir(), "known_hosts") + if err := os.WriteFile(kh, nil, 0o600); err != nil { + t.Fatal(err) + } + h, priv := signedHandler(t) + + if _, err := h.HandleMessage(context.Background(), discoverySyncMsg(t, true, kh, priv)); err != nil { + t.Fatalf("sync: %v", err) + } + entries := h.registry.List() + if e, ok := entries["ds-disc"]; !ok || e.ProxyType != "discovery-proxy" { + t.Fatalf("discovery datasource not registered (unknown proxy type?): %+v", entries) + } + sib, ok := entries["ds-disc:ssh"] + if !ok { + t.Fatalf("ssh sibling not registered: %+v", entries) + } + if sib.Type != "ssh" || sib.ProxyType != "ssh-proxy" { + t.Errorf("sibling type = %s/%s", sib.Type, sib.ProxyType) + } + + if _, err := h.HandleMessage(context.Background(), discoverySyncMsg(t, false, kh, priv)); err != nil { + t.Fatalf("second sync: %v", err) + } + if _, ok := h.registry.List()["ds-disc:ssh"]; ok { + t.Error("sibling survived ssh_access being turned off") + } + if _, ok := h.registry.List()["ds-disc"]; !ok { + t.Error("discovery datasource removed along with the sibling") + } +} + +// Without signing the sibling must not start, even though the flag is set. +func TestConfigSync_SSHAccessRefusedWhenUnsigned(t *testing.T) { + kh := filepath.Join(t.TempDir(), "known_hosts") + if err := os.WriteFile(kh, nil, 0o600); err != nil { + t.Fatal(err) + } + h := newTestHandler(t) // verifier disabled + _, priv, _ := ed25519.GenerateKey(nil) + + if _, err := h.HandleMessage(context.Background(), discoverySyncMsg(t, true, kh, priv)); err != nil { + t.Fatalf("sync: %v", err) + } + entries := h.registry.List() + if _, ok := entries["ds-disc"]; !ok { + t.Fatal("discovery datasource should still register") + } + if _, ok := entries["ds-disc:ssh"]; ok { + t.Error("ssh sibling registered without signing") + } +} + +// A push that omits "config" must not panic when allowed_hosts is injected. +func TestConfigSync_MissingConfigWithAllowedHosts(t *testing.T) { + h := newTestHandler(t) + for _, pt := range []string{"discovery-proxy", "ssh-proxy"} { + msg := `{"action":"datasource_config_sync","account_id":"a","datasources":[` + + `{"id":"ds-1","type":"x","proxy_type":"` + pt + `","name":"n",` + + `"allowed_hosts":["10.0.0.0/24"],"credential_source":"cloud_push"}]}` + if _, err := h.HandleMessage(context.Background(), []byte(msg)); err != nil { + t.Fatalf("%s: %v", pt, err) + } + } +}