diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35eaa2b..1528307 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24eb154..7306858 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index ca1f556..8d881fe 100644 --- a/README.md +++ b/README.md @@ -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= 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 diff --git a/deploy/install.sh b/deploy/install.sh index e9b598f..391f0d3 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -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= bash INSTALL_DIR="/usr/local/bin" CONFIG_DIR="/etc/nudgebee" @@ -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' @@ -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)" @@ -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 + 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 + 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) @@ -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}" @@ -132,7 +269,7 @@ 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" < 0 { diff --git a/pkg/secrets/gcp_sm.go b/pkg/secrets/gcp_sm.go index 5abd90e..b941537 100644 --- a/pkg/secrets/gcp_sm.go +++ b/pkg/secrets/gcp_sm.go @@ -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" @@ -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 {