Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions cmd/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
mayankpande88 marked this conversation as resolved.

handler := ws.NewHandler(registry, credStore, secretsMgr, verifier, logger)
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module nudgebee/forager

go 1.25.13
go 1.26.6

require (
cloud.google.com/go/auth v0.23.1
Expand All @@ -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
Expand Down Expand Up @@ -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
)
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
21 changes: 21 additions & 0 deletions pkg/proxy/discovery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<N>.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, `<id>:ssh` named `<name>-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
Expand Down
2 changes: 1 addition & 1 deletion pkg/proxy/discovery/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions pkg/proxy/discovery/ssh_access.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
mayankpande88 marked this conversation as resolved.

// 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")
}
Comment thread
mayankpande88 marked this conversation as resolved.

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
}
Comment thread
blue4209211 marked this conversation as resolved.
}
Loading
Loading