Skip to content
Open
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
go-version: '1.26.6'
cache: true

- name: Format check
Expand All @@ -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 All @@ -41,7 +41,7 @@ jobs:

- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
go-version: '1.26.6'
cache: true

- name: govulncheck
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ jobs:

- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
go-version: '1.26.6'
cache: true

- name: Format check
Expand All @@ -56,7 +56,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 Expand Up @@ -87,7 +87,7 @@ jobs:

- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: go.mod
go-version: '1.26.6'
cache: true

- name: Resolve version metadata
Expand Down Expand Up @@ -280,6 +280,7 @@ jobs:
cp deploy/install.sh dist/install.sh
cp deploy/install-macos.sh dist/install-macos.sh
cp deploy/install.ps1 dist/install.ps1
cp docs/content-packs/linux-inventory-example.yaml dist/linux-inventory-v2.yaml

- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ curl -fsSL https://github.com/nudgebee/forager/releases/latest/download/install.
Installs the binary to `/usr/local/bin/nudgebee-forager`, drops config
under `/etc/nudgebee/`, and registers a systemd unit.

To configure a discovery datasource during installation, provide the signed
pack verification key and the SSH key already present on the host:

```bash
curl -fsSL https://github.com/nudgebee/forager/releases/latest/download/install.sh | \
sudo NB_DATASOURCES=discovery \
NB_DISCOVERY_ALLOWED_CIDRS=10.0.0.0/24 \
NB_DISCOVERY_SSH_USERNAME=nudgebee-ro \
NB_DISCOVERY_SSH_PRIVATE_KEY_FILE=/root/.ssh/id_ed25519 \
NB_PACK_PUBLIC_KEY=<base64-ed25519-public-key> bash
```

The installer downloads and verifies `linux-inventory-v2.yaml`, stores it in
`/etc/nudgebee/packs/`, copies the private key into Forager-owned storage, and
writes the discovery datasource into `forager.yaml`. Existing configuration is
preserved unless `NB_REPLACE_CONFIG=true` is set. Set
`NB_DATASOURCES=interactive` to answer the same discovery questions in a
terminal.

For a generic SSH datasource, use `NB_DATASOURCES=ssh` with
`NB_SSH_NAME`, `NB_SSH_ALLOWED_HOSTS`, `NB_SSH_USERNAME`, and
`NB_SSH_PRIVATE_KEY_FILE`. Set `NB_DATASOURCES=discovery,ssh` to configure both
in one installation.

### macOS

```bash
Expand Down
151 changes: 145 additions & 6 deletions deploy/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ set -euo pipefail
# Usage:
# curl -fsSL https://github.com/nudgebee/forager/releases/latest/download/install.sh \
# | sudo NB_ACCESS_KEY=xxx NB_ACCESS_SECRET=yyy bash
#
# Optional discovery setup (non-interactive):
# NB_DATASOURCES=discovery NB_DISCOVERY_ALLOWED_CIDRS=10.0.0.0/24 \
# NB_DISCOVERY_SSH_USERNAME=nudgebee-ro \
# NB_DISCOVERY_SSH_PRIVATE_KEY_FILE=/root/.ssh/id_ed25519 \
# NB_PACK_PUBLIC_KEY=<base64-ed25519-public-key> bash

INSTALL_DIR="/usr/local/bin"
CONFIG_DIR="/etc/nudgebee"
Expand All @@ -17,6 +23,21 @@ BINARY_NAME="nudgebee-forager"
DOWNLOAD_BASE="${NB_DOWNLOAD_URL:-https://github.com/nudgebee/forager/releases}"
VERSION="${NB_VERSION:-latest}"
RELAY_URL="${NB_RELAY_URL:-wss://relay.nudgebee.com/register}"
DATASOURCES="${NB_DATASOURCES:-}"
REPLACE_CONFIG="${NB_REPLACE_CONFIG:-false}"
PACK_PUBLIC_KEY="${NB_PACK_PUBLIC_KEY:-}"
PACK_URL="${NB_PACK_URL:-}"
DISCOVERY_NAME="${NB_DISCOVERY_NAME:-linux-inventory}"
DISCOVERY_SSH_USERNAME="${NB_DISCOVERY_SSH_USERNAME:-nudgebee-ro}"
DISCOVERY_SSH_KEY_FILE="${NB_DISCOVERY_SSH_PRIVATE_KEY_FILE:-}"
DISCOVERY_ALLOWED_CIDRS="${NB_DISCOVERY_ALLOWED_CIDRS:-}"
SSH_NAME="${NB_SSH_NAME:-ssh}"
SSH_ALLOWED_HOSTS="${NB_SSH_ALLOWED_HOSTS:-}"
SSH_USERNAME="${NB_SSH_USERNAME:-}"
SSH_KEY_FILE="${NB_SSH_PRIVATE_KEY_FILE:-}"
PACK_DIR="${CONFIG_DIR}/packs"
TEMP_DIR=""
PACK_TMP_FILE=""

# Colors for output
RED='\033[0;31m'
Expand All @@ -28,6 +49,11 @@ log() { echo -e "${GREEN}[nudgebee]${NC} $*"; }
warn() { echo -e "${YELLOW}[nudgebee]${NC} $*"; }
err() { echo -e "${RED}[nudgebee]${NC} $*" >&2; }

cleanup_temp_files() {
[ -z "$PACK_TMP_FILE" ] || rm -f "$PACK_TMP_FILE"
[ -z "$TEMP_DIR" ] || rm -rf "$TEMP_DIR"
}

check_root() {
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root (use sudo)"
Expand All @@ -47,6 +73,118 @@ check_required_vars() {
fi
}

yaml_quote() {
local value="$1"
value="${value//\'/\'\'}"
printf "'%s'" "$value"
}

validate_name() {
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || {
err "Invalid datasource name '$1' (use letters, numbers, '.', '_' or '-')"
return 1
}
}

validate_list() {
local value="$1" label="$2" item

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The items array variable is not declared as local, which pollutes the global shell scope. Declare it as a local array.

Suggested change
local value="$1" label="$2" item
local value="$1" label="$2" item
local -a items

local -a items
[ -n "$value" ] || { err "$label is required"; return 1; }
IFS=',' read -r -a items <<< "$value"
for item in "${items[@]}"; do
item="${item#${item%%[![:space:]]*}}"
item="${item%${item##*[![:space:]]}}"
[ -n "$item" ] || { err "$label contains an empty entry"; return 1; }
[[ "$item" =~ ^[A-Za-z0-9:./_-]+$ ]] || { err "Invalid $label entry '$item'"; return 1; }
done
}

prompt_discovery() {
local tty=/dev/tty value
[ -r "$tty" ] || { err "Interactive setup requires a terminal"; exit 1; }
read -r -p "Discovery datasource name [linux-inventory]: " value <"$tty"; DISCOVERY_NAME="${value:-linux-inventory}"
read -r -p "Discovery CIDRs (comma-separated): " DISCOVERY_ALLOWED_CIDRS <"$tty"
read -r -p "SSH username [nudgebee-ro]: " value <"$tty"; DISCOVERY_SSH_USERNAME="${value:-nudgebee-ro}"
read -r -p "SSH private key file: " DISCOVERY_SSH_KEY_FILE <"$tty"
read -r -p "Pack public key (base64): " PACK_PUBLIC_KEY <"$tty"
}

prepare_pack() {
[ -n "$PACK_PUBLIC_KEY" ] || { err "NB_PACK_PUBLIC_KEY is required for discovery setup"; return 1; }
mkdir -p "$PACK_DIR"
local url tmp_pack
if [ -n "$PACK_URL" ]; then
url="$PACK_URL"
elif [ "$VERSION" = "latest" ]; then
url="${DOWNLOAD_BASE}/latest/download/linux-inventory-v2.yaml"
else
url="${DOWNLOAD_BASE}/download/${VERSION}/linux-inventory-v2.yaml"
fi
PACK_TMP_FILE="$(mktemp)"
tmp_pack="$PACK_TMP_FILE"
if command -v curl &>/dev/null; then curl -fsSL -o "$tmp_pack" "$url"; elif command -v wget &>/dev/null; then wget -q -O "$tmp_pack" "$url"; else err "Neither curl nor wget found"; return 1; fi
[ -s "$tmp_pack" ] || { rm -f "$tmp_pack"; err "Downloaded inventory pack is empty"; return 1; }
"$INSTALL_DIR/$BINARY_NAME" pack verify "$tmp_pack" --key "$PACK_PUBLIC_KEY" >/dev/null
install -o nudgebee -g nudgebee -m 0644 "$tmp_pack" "${PACK_DIR}/linux-inventory-v2.yaml"
}

copy_ssh_key() {
local source="$1" name="$2" destination="${DATA_DIR}/ssh/${name}_id_ed25519"
[ -n "$source" ] || { err "SSH private key file is required for datasource '$name'"; return 1; }
[ -r "$source" ] || { err "SSH private key file is not readable: $source"; return 1; }
[ -s "$source" ] || { err "SSH private key file is empty: $source"; return 1; }
install -d -o nudgebee -g nudgebee -m 0700 "${DATA_DIR}/ssh"
install -o nudgebee -g nudgebee -m 0600 "$source" "$destination"
printf '%s' "$destination"
}

configure_datasources() {
[ -n "$DATASOURCES" ] || return 0
if [ -f "${CONFIG_DIR}/forager.yaml" ] && [ "$REPLACE_CONFIG" != "true" ]; then
err "${CONFIG_DIR}/forager.yaml already exists; set NB_REPLACE_CONFIG=true to regenerate it"
exit 1
fi
if [ "$DATASOURCES" = "interactive" ]; then
prompt_discovery
DATASOURCES=discovery
fi
local key_path item

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The items array variable is not declared as local, which pollutes the global shell scope. Declare it as a local array.

Suggested change
local key_path item
local key_path item
local -a items

local -a items
DATASOURCE_YAML="datasources:\n"
case ",$DATASOURCES," in
*,discovery,*)
validate_name "$DISCOVERY_NAME"
validate_list "$DISCOVERY_ALLOWED_CIDRS" "NB_DISCOVERY_ALLOWED_CIDRS"
key_path="$(copy_ssh_key "$DISCOVERY_SSH_KEY_FILE" "$DISCOVERY_NAME")"
prepare_pack
DATASOURCE_YAML+=" - type: discovery\n name: $(yaml_quote "$DISCOVERY_NAME")\n allowed_hosts:\n"
IFS=',' read -r -a items <<< "$DISCOVERY_ALLOWED_CIDRS"
for item in "${items[@]}"; do
item="${item#${item%%[![:space:]]*}}"; item="${item%${item##*[![:space:]]}}"
DATASOURCE_YAML+=" - $(yaml_quote "$item")\n"
done
DATASOURCE_YAML+=" discovery:\n pack_public_key: $(yaml_quote "$PACK_PUBLIC_KEY")\n pack_dir: $(yaml_quote "$PACK_DIR")\n credential_source: local\n credentials:\n username: $(yaml_quote "$DISCOVERY_SSH_USERNAME")\n private_key_file: $(yaml_quote "$key_path")\n"
;;
esac
case ",$DATASOURCES," in
*,ssh,*)
validate_name "$SSH_NAME"
validate_list "$SSH_ALLOWED_HOSTS" "NB_SSH_ALLOWED_HOSTS"
[ -n "$SSH_USERNAME" ] || { err "NB_SSH_USERNAME is required"; exit 1; }
key_path="$(copy_ssh_key "$SSH_KEY_FILE" "$SSH_NAME")"
DATASOURCE_YAML+=" - type: ssh\n name: $(yaml_quote "$SSH_NAME")\n allowed_hosts:\n"
IFS=',' read -r -a items <<< "$SSH_ALLOWED_HOSTS"
for item in "${items[@]}"; do
item="${item#${item%%[![:space:]]*}}"; item="${item%${item##*[![:space:]]}}"
DATASOURCE_YAML+=" - $(yaml_quote "$item")\n"
done
DATASOURCE_YAML+=" credential_source: local\n credentials:\n username: $(yaml_quote "$SSH_USERNAME")\n private_key_file: $(yaml_quote "$key_path")"
;;
*,discovery,*|*,ssh,*) ;;
*) err "NB_DATASOURCES supports discovery and ssh"; exit 1 ;;
esac
}

detect_platform() {
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
Expand Down Expand Up @@ -90,11 +228,10 @@ download_binary() {
# install is enough to trigger it.
local tmpdir
tmpdir="$(mktemp -d)" || { err "Could not create a temporary directory"; exit 1; }
# Double-quoted so ${tmpdir} expands now. Single quotes defer expansion
# to when the trap fires, by which point this function has returned and
# its local is out of scope — the trap would run `rm -rf ""` and leave
# the directory behind on every successful install.
trap "rm -rf \"${tmpdir}\"" EXIT
# Keep cleanup in one EXIT trap so both binary and pack downloads are
# removed on success and on any failure path.
TEMP_DIR="$tmpdir"
trap cleanup_temp_files EXIT

local tmpbin="${tmpdir}/${BINARY_NAME}"

Expand Down Expand Up @@ -132,14 +269,15 @@ create_config() {
chown nudgebee:nudgebee "$DATA_DIR"

# Only write config if it doesn't exist (don't overwrite on upgrade)
if [ ! -f "${CONFIG_DIR}/forager.yaml" ]; then
if [ ! -f "${CONFIG_DIR}/forager.yaml" ] || { [ "$REPLACE_CONFIG" = "true" ] && [ -n "$DATASOURCES" ]; }; then
log "Writing config to ${CONFIG_DIR}/forager.yaml..."
cat > "${CONFIG_DIR}/forager.yaml" <<EOF
relay_url: ${RELAY_URL}
access_key: ${NB_ACCESS_KEY}
access_secret: ${NB_ACCESS_SECRET}
data_dir: ${DATA_DIR}
$([ -n "${NB_SIGNING_PUBLIC_KEY:-}" ] && echo "signing_public_key: \"${NB_SIGNING_PUBLIC_KEY}\"")
$(printf '%b\n' "${DATASOURCE_YAML:-}")
EOF
chmod 600 "${CONFIG_DIR}/forager.yaml"
chown nudgebee:nudgebee "${CONFIG_DIR}/forager.yaml"
Expand Down Expand Up @@ -209,6 +347,7 @@ main() {
detect_platform
download_binary
create_user
configure_datasources
create_config
install_systemd_service
print_status
Expand Down
4 changes: 2 additions & 2 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.0

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
4 changes: 2 additions & 2 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
4 changes: 2 additions & 2 deletions pkg/proxy/discovery/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,11 @@ 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 // cmd is reused for multiple response maps below.

done := s.trackExec()
commandDelay := s.delay
if d, ok := s.delayFor[cmd]; ok {
if d, ok := s.delayFor[string(req.Payload[4:])]; ok {
commandDelay = d
}
if commandDelay > 0 {
Expand Down
11 changes: 1 addition & 10 deletions pkg/secrets/gcp_sm.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"log/slog"
"sync"

"cloud.google.com/go/auth/credentials"
secretmanager "cloud.google.com/go/secretmanager/apiv1"
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
"google.golang.org/api/option"
Expand Down Expand Up @@ -59,15 +58,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"},
})
if err != nil {
g.initErr = fmt.Errorf("gcp_sm: load credentials from %s: %w", g.credentialsFile, err)
return
}
opts = append(opts, option.WithAuthCredentials(creds))
opts = append(opts, option.WithCredentialsFile(g.credentialsFile)) //nolint:staticcheck // preserve support for all ADC credential file types.
}
client, err := secretmanager.NewClient(ctx, opts...)
if err != nil {
Expand Down
Loading