diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1136387..d5109a2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,6 +21,13 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 + + - name: Install dependencies + run: | + sudo apt update && sudo apt install -y curl git gcc g++ make \ + gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu \ + libc6-dev-arm64-cross software-properties-common + - name: Checkout uses: actions/checkout@v4 @@ -53,6 +60,6 @@ jobs: args: release --clean id: goreleaser - name: Attest Binaries - uses: actions/attest-build-provenance@v1 + uses: actions/attest-build-provenance@v2 with: - subject-path: '${{ github.workspace }}/dist/magellan_linux_amd64_v3/magellan' + subject-checksums: dist/checksums.txt diff --git a/.github/workflows/prbuild.yml b/.github/workflows/prbuild.yml index 476ba60..6621214 100644 --- a/.github/workflows/prbuild.yml +++ b/.github/workflows/prbuild.yml @@ -20,6 +20,13 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v3 + + - name: Install dependencies + run: | + sudo apt update && sudo apt install -y curl git gcc g++ make \ + gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu \ + libc6-dev-arm64-cross software-properties-common + - name: Checkout uses: actions/checkout@v4 with: @@ -41,4 +48,4 @@ jobs: with: version: '~> v2' args: release --snapshot - id: goreleaser \ No newline at end of file + id: goreleaser diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 24419e5..04df1f4 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -27,18 +27,20 @@ builds: - version goos: - linux - - darwin - - windows goarch: - amd64 - arm64 goamd64: - v3 + goarm: + - 7 env: - - CGO_ENABLED=0 + - CGO_ENABLED=1 + - CC={{ if eq .Arch "arm64" }}aarch64-linux-gnu-gcc{{ else }}gcc{{ end }} + - CXX={{ if eq .Arch "arm64" }}aarch64-linux-gnu-g++{{ else }}g++{{ end }} archives: - - format: tar.gz + - formats: [ 'tar.gz' ] # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ @@ -54,23 +56,22 @@ archives: - magellan.1 nfpms: - - id: magellan - formats: - - deb - - rpm - - apk - - archlinux - maintainer: "David J. Allen " - description: "Magellan is a discovery tool for BMCs." - homepage: "https://www.davidallendj.org" - license: MIT - section: utils - priority: optional - contents: - - src: dist/magellan_{{ .Os }}_{{ if eq .Arch "amd64" }}{{ .Arch }}_{{ .Amd64 }}{{ else }}{{ .Arch }}{{ end }}/magellan - dst: /usr/local/bin/magellan - - src: magellan.1 - dst: /usr/share/man/man1/ + - id: magellan + formats: + - deb + - rpm + - apk + - archlinux + maintainer: "David J. Allen " + description: "Magellan is a discovery tool for BMCs." + homepage: "https://www.openchami.org" + license: MIT + section: utils + priority: optional + contents: + - src: magellan.1 + dst: /usr/share/man/man1/ + dockers: @@ -93,7 +94,7 @@ dockers: - CHANGELOG.md - README.md - image_templates: - - &arm64v8_linux_image ghcr.io/davidallendj/{{.ProjectName}}:{{ .Tag }}-arm64 + - &arm64v7_linux_image ghcr.io/davidallendj/{{.ProjectName}}:{{ .Tag }}-arm64 - ghcr.io/davidallendj/{{.ProjectName}}:{{ .Major }}-arm64 - ghcr.io/davidallendj/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}-arm64 use: buildx @@ -114,25 +115,22 @@ docker_manifests: - name_template: "ghcr.io/davidallendj/{{.ProjectName}}:latest" image_templates: - *amd64_linux_image - - *arm64v8_linux_image + - *arm64v7_linux_image - name_template: "ghcr.io/davidallendj/{{.ProjectName}}:{{ .Tag }}" image_templates: - *amd64_linux_image - - *arm64v8_linux_image + - *arm64v7_linux_image - name_template: "ghcr.io/davidallendj/{{.ProjectName}}:{{ .Major }}" image_templates: - *amd64_linux_image - - *arm64v8_linux_image + - *arm64v7_linux_image - name_template: "ghcr.io/davidallendj/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}" image_templates: - *amd64_linux_image - - *arm64v8_linux_image - - - + - *arm64v7_linux_image checksum: name_template: 'checksums.txt' diff --git a/Makefile b/Makefile index 5495893..a28a664 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,7 @@ mod: ## go mod tidy inst: ## go install tools $(call print-target) go install github.com/client9/misspell/cmd/misspell@v0.3.4 - go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2 + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.0.1 go install github.com/goreleaser/goreleaser/v2@v2.3.2 go install github.com/cpuguy83/go-md2man/v2@latest @@ -71,7 +71,7 @@ build: ## go build container: ## docker build container: $(call print-target) - docker build . --build-arg REGISTRY_HOST=${REGISTRY_HOST} --no-cache --pull --tag '${NAME}:${VERSION}' + docker build . --build-arg REGISTRY_HOST=${REGISTRY_HOST} --no-cache --pull --tag '${NAME}:${VERSION}' .PHONY: spell spell: ## misspell diff --git a/README.md b/README.md index 369f38f..a71f0a0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,32 @@ The `magellan` CLI tool is a Redfish-based, board management controller (BMC) discovery tool designed to scan networks and is written in Go. The tool collects information from BMC nodes using the provided Redfish RESTful API with [`gofish`](https://github.com/stmcginnis/gofish) and loads the queried data into an [SMD](https://github.com/davidallendj/smd/tree/master) instance. The tool strives to be more flexible by implementing multiple methods of discovery to work for a wider range of systems (WIP) and is capable of using independently of other tools or services. -**Note: `magellan` v0.1.0 is incompatible with SMD v2.15.3 and earlier.** +> [!NOTE] +> The v0.1.0 version of `magellan` is incompatible with `smd` v2.15.3 and earlier due to `smd` lacking the inventory parsing code used with `magellan`'s output.** + + + + * [Main Features](#main-features) + * [Getting Started](#getting-started) + * [Building the Executable](#building-the-executable) + + [Building on Debian 12 (Bookworm)](#building-on-debian-12-bookworm) + + [Docker](#docker) + + [Arch Linux (AUR)](#arch-linux-aur) + * [Usage](#usage) + + [Checking for Redfish](#checking-for-redfish) + + [Running the Tool](#running-the-tool) + + [Managing Secrets](#managing-secrets) + + [Starting the Emulator](#starting-the-emulator) + + [Updating Firmware](#updating-firmware) + + [Getting an Access Token (WIP)](#getting-an-access-token-wip) + + [Running with Docker](#running-with-docker) + * [How It Works](#how-it-works) + * [TODO](#todo) + * [Copyright](#copyright) + + + + ## Main Features @@ -13,6 +38,7 @@ The `magellan` tool comes packed with a handleful of features for doing discover - Redfish-based firmware updating - Integration with davidallendj SMD - Write inventory data to JSON +- Store and manage BMC secrets See the [TODO](#todo) section for a list of soon-ish goals planned. @@ -40,7 +66,7 @@ Getting the `magellan` tool to work with Go 1.21 on Debian 12 may require instal apt install gcc golang-1.21/bookworm-backport ``` -The binary executable for the `golang-1.21` executable can then be found using `dpkg`. +The binary executable for the `golang-1.21` executable can then be found using `dpkg`.v2.0.1 ```bash dpkg -L golang-1.21-go @@ -49,7 +75,7 @@ dpkg -L golang-1.21-go Using the correct binary, set the `CGO_ENABLED` environment variable and build the executable with `cgo` enabled: ```bash -export GOBIN=/usr/bin/golang-1.21/bin/go +export GOBIN=/usr/bin/golang-1.21/bin/go go env -w CGO_ENABLED=1 go mod tidy && go build ``` @@ -66,6 +92,17 @@ docker pull ghcr.io/davidallendj/magellan:latest See the ["Running with Docker"](#running-with-docker) section below about running with the Docker container. + +### Arch Linux (AUR) + +The `magellan` tool is in the AUR as a binary package and can be installed via your favorite AUR helper. + +```bash +yay -S magellan-bin +``` +> [!NOTE] +> The AUR package may not always be in sync with the latest release. It is recommended to install `magellan` from source for the latest version. + ## Usage The sections below assume that the BMC nodes have an IP address available to query Redfish. Currently, `magellan` does not support discovery with MAC addresses although that may change in the future. @@ -173,14 +210,101 @@ This will initiate a crawler that will find as much inventory data as possible. Note: If the `cache` flag is not set, `magellan` will use `/tmp/$USER/magellan.db` by default. +### Managing Secrets + +When connecting to an array of BMC nodes, some nodes may have different secret credentials than the rest. These secrets can be stored and used automatically by `magellan` when performing a `collect` or a `crawl`. All secrets are encrypted and are only accessible using the same `MASTER_KEY` as when stored originally. + +To store secrets using `magellan`: + +1. Set the `MASTER_KEY` environment variable. This can be generated using `magellan secrets generatekey`. + +```bash +export MASTER_KEY=$(magellan secrets generatekey) +``` + +2. Store secret credentials for hosts shown by `magellan list`: + +```bash +export bmc_host=https://172.16.0.105:443 +magellan secrets store $bmc_host $bmc_username:$bmc_password +``` + +There should be no output unless an error occurred. + +3. Print the list of hosts to confirm secrets are stored. + +```bash +magellan secrets list +``` + +If you see your `bmc_host` listed in the output, that means that your secrets were stored successfully. + +Additionally, if you want to see the actually contents, make sure the `MASTER_KEY` environment variable is correctly set and do the following: + +```bash +magellan secrets retrieve $bmc_host +``` + +4. Run either a `crawl` or `collect` and `magellan` should be a do find the credentials for each host. + +```bash +magellan crawl -i $bmc_host +magellan collect \ + --username $default_bmc_username \ + --password $default_bmc_password +``` + +If you pass arguments with the `--username/--password` flags, the arguments will override all credentials set in the secret store for each flag. However, it is possible only override a single flag (e.g. `magellan collect --username`). + +> [!NOTE] +> Make sure that the `secretID` is EXACTLY as show with `magellan list`. Otherwise, `magellan` will not be able to do the lookup from the secret store correctly. + +> [!TIP] +> You can set default fallback credentials by storing a secret with the `secretID` of "default". This is used if no `secretID` is found in the local store for the specified host. This is useful when you want to set a username and password that is the same for all BMCs with the exception of the ones specified. +> ```bash +> magellan secrets default $username:$password +> ``` + +### Starting the Emulator + +This repository includes a quick and dirty way to test `magellan` using a Redfish emulator with little to no effort to get running. + +1. Make sure you have `docker` with Docker compose and optionally `make`. + +2. Run the `emulator/setup.sh` script or alternatively `make emulator`. + +This will start a flask server that you can make requests to using `curl`. + +```bash +export emulator_host=https://172.21.0.2:5000 +export emulator_username=root # set in the `rf_emulator.yml` file +export emulator_password=root_password # set in the `rf_emulator.yml` file +curl -k $emulator_host/redfish/v1 -u $emulator_username:$emulator_password +``` + +...or with `magellan` using the secret store... + +```bash +magellan scan --subnet 172.21.0.0/24 +magellan secrets store \ + $emulator_host \ + $emulator_username:$emulator_password +magellan collect --host https://smd.openchami.cluster +``` + +This example should work just like running on real hardware. + +> [!NOTE] +> The emulator host may be different from the one in the README. Make sure to double-check the host! + ### Updating Firmware -The `magellan` tool is capable of updating firmware with using the `update` subcommand via the Redfish API. This may sometimes necessary if some of the `collect` output is missing or is not including what is expected. The subcommand expects there to be a running HTTP/HTTPS server running that has an accessible URL path to the firmware download. Specify the URL with the `--firmware-path` flag and the firmware type with the `--component` flag with all the other usual arguments like in the example below: +The `magellan` tool is capable of updating firmware with using the `update` subcommand via the Redfish API. This may sometimes necessary if some of the `collect` output is missing or is not including what is expected. The subcommand expects there to be a running HTTP/HTTPS server running that has an accessible URL path to the firmware download. Specify the URL with the `--firmware-path` flag and the firmware type with the `--component` flag (optional) with all the other usual arguments like in the example below: ```bash ./magellan update 172.16.0.108:443 \ - --username $USERNAME \ - --password $PASSWORD \ + --username $bmc_username \ + --password $bmc_password \ --firmware-path http://172.16.0.255:8005/firmware/bios/image.RBU \ --component BIOS ``` @@ -188,9 +312,12 @@ The `magellan` tool is capable of updating firmware with using the `update` subc Then, the update status can be viewed by including the `--status` flag along with the other usual arguments or with the `watch` command: ```bash -./magellan update 172.16.0.110 --status --username $USERNAME --pass $PASSWORD | jq '.' +./magellan update 172.16.0.110 \ + --status \ + --username $bmc_username \ + --password $bmc_password | jq '.' # ...or... -watch -n 1 "./magellan update 172.16.0.110 --status --username $USERNAME --password $PASSWORD | jq '.'" +watch -n 1 "./magellan update 172.16.0.110 --status --username $bmc_username --password $bmc_password | jq '.'" ``` ### Getting an Access Token (WIP) @@ -258,8 +385,10 @@ See the [issue list](https://github.com/davidallendj/magellan/issues) for plans * [ ] Separate `collect` subcommand with making request to endpoint * [X] Support logging in with `opaal` to get access token * [X] Support using CA certificates with HTTP requests to SMD -* [ ] Add tests for the regressions and compatibility +* [X] Add tests for the regressions and compatibility * [X] Clean up, remove unused, and tidy code (first round) +* [X] Add `secrets` command to manage secret credentials +* [ ] Add server component to make `magellan` a micro-service ## Copyright diff --git a/build-in-container.sh b/build-in-container.sh new file mode 100644 index 0000000..ba07c82 --- /dev/null +++ b/build-in-container.sh @@ -0,0 +1,60 @@ +#!/bin/bash + +# This script uses the latest Ubuntu 24.04 container to build the project with GoReleaser. It emulates the GitHub Actions environment as closely as possible. +# Before submitting a PR for release/build. please run this script to ensure your PR will pass the build. + +# Name of the container +CONTAINER_NAME="goreleaser-build" + +# Directory where built binaries will be available +OUTPUT_DIR="$(pwd)/dist" + +export GIT_STATE=$(if git diff-index --quiet HEAD --; then echo 'clean'; else echo 'dirty'; fi) +export BUILD_HOST=$(hostname) +export GO_VERSION=$(go version | awk '{print $3}') +export BUILD_USER=$(whoami) + +# Start a new disposable Ubuntu 24.04 container with the current directory mounted +${CONTAINER_CMD:-docker} run --rm -it \ + --name "$CONTAINER_NAME" \ + -v "$(pwd)":/workspace \ + -w /workspace \ + ubuntu:24.04 bash -c " + + # Suppress timezone prompts + export DEBIAN_FRONTEND=noninteractive + export TZ=UTC + + + # Update package lists and install dependencies + apt update && apt install -y curl git gcc g++ make \ + gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu \ + libc6-dev-arm64-cross software-properties-common + + # Install Go (match GitHub runner version) + curl -fsSL https://golang.org/dl/go1.21.5.linux-amd64.tar.gz | tar -C /usr/local -xz + export PATH=\$PATH:/usr/local/go/bin + go version # Verify Go installation + + # Set GOPATH and update PATH to include Go binaries + export GOPATH=\$(go env GOPATH) + export PATH=\$PATH:\$GOPATH/bin + echo \"GOPATH: \$GOPATH\" && echo \"PATH: \$PATH\"`` + + # Install Goreleaser + curl -sL https://github.com/goreleaser/goreleaser/releases/latest/download/goreleaser_Linux_x86_64.tar.gz | tar -xz -C /usr/local/bin + goreleaser --version # Verify Goreleaser installation + + # Set Build Environment Variables + export GIT_STATE="$GIT_STATE" + export BUILD_HOST="$BUILD_HOST" + export BUILD_USER="$BUILD_USER" + export GO_VERSION=$(go version | awk '{print $3}') + + # Run Goreleaser + goreleaser build --snapshot --clean +" + +# Notify user of success +echo "✅ Build complete! Check the output in: $OUTPUT_DIR" + diff --git a/cmd/cache.go b/cmd/cache.go index 11c87a5..f39bffd 100644 --- a/cmd/cache.go +++ b/cmd/cache.go @@ -6,8 +6,8 @@ import ( "os" "strconv" - magellan "github.com/davidallendj/magellan/internal" "github.com/davidallendj/magellan/internal/cache/sqlite" + magellan "github.com/davidallendj/magellan/pkg" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) diff --git a/cmd/collect.go b/cmd/collect.go index 53807c3..c3fb35c 100644 --- a/cmd/collect.go +++ b/cmd/collect.go @@ -1,14 +1,17 @@ package cmd import ( + "encoding/json" "fmt" "os/user" "github.com/cznic/mathutil" - magellan "github.com/davidallendj/magellan/internal" "github.com/davidallendj/magellan/internal/cache/sqlite" urlx "github.com/davidallendj/magellan/internal/url" + magellan "github.com/davidallendj/magellan/pkg" "github.com/davidallendj/magellan/pkg/auth" + "github.com/davidallendj/magellan/pkg/bmc" + "github.com/davidallendj/magellan/pkg/secrets" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -18,13 +21,19 @@ import ( // This command should be ran after the `scan` to find available hosts // on a subnet. var CollectCmd = &cobra.Command{ - Use: "collect", + Use: "collect", + Example: ` // basic collect after scan without making a follow-up request + magellan collect --cache ./assets.db --cacert ochami.pem -o ./logs -t 30 + + // set username and password for all nodes and make request to specified host + magellan collect --host https://smd.openchami.cluster -u $bmc_username -p $bmc_password + + // run a collect using secrets manager with fallback username and password + export MASTER_KEY=$(magellan secrets generatekey) + magellan secrets store $node_creds_json -f nodes.json + magellan collect --host https://smd.openchami.cluster -u $fallback_bmc_username -p $fallback_bmc_password`, Short: "Collect system information by interrogating BMC node", - Long: "Send request(s) to a collection of hosts running Redfish services found stored from the 'scan' in cache.\n" + - "See the 'scan' command on how to perform a scan.\n\n" + - "Examples:\n" + - " magellan collect --cache ./assets.db --output ./logs --timeout 30 --cacert cecert.pem\n" + - " magellan collect --host smd.example.com --port 27779 --username username --password password", + Long: "Send request(s) to a collection of hosts running Redfish services found stored from the 'scan' in cache.\nSee the 'scan' command on how to perform a scan.", Run: func(cmd *cobra.Command, args []string) { // get probe states stored in db from scan scannedResults, err := sqlite.GetScannedAssets(cachePath) @@ -47,18 +56,67 @@ var CollectCmd = &cobra.Command{ } } - if verbose { - log.Debug().Str("Access Token", accessToken) - } - - // + // set the minimum/maximum number of concurrent processes if concurrency <= 0 { concurrency = mathutil.Clamp(len(scannedResults), 1, 10000) } - err = magellan.CollectInventory(&scannedResults, &magellan.CollectParams{ + + // use secret store for BMC credentials, and/or credential CLI flags + var store secrets.SecretStore + if username != "" && password != "" { + // First, try and load credentials from --username and --password if both are set. + log.Debug().Msgf("--username and --password specified, using them for BMC credentials") + store = secrets.NewStaticStore(username, password) + } else { + // Alternatively, locate specific credentials (falling back to default) and override those + // with --username or --password if either are passed. + log.Debug().Msgf("one or both of --username and --password NOT passed, attempting to obtain missing credentials from secret store at %s", secretsFile) + if store, err = secrets.OpenStore(secretsFile); err != nil { + log.Error().Err(err).Msg("failed to open local secrets store") + } + + // Temporarily override username/password of each BMC if one of those + // flags is passed. The expectation is that if the flag is specified + // on the command line, it should be used. + if username != "" { + log.Info().Msg("--username passed, temporarily overriding all usernames from secret store with value") + } + if password != "" { + log.Info().Msg("--password passed, temporarily overriding all passwords from secret store with value") + } + switch s := store.(type) { + case *secrets.StaticStore: + if username != "" { + s.Username = username + } + if password != "" { + s.Password = password + } + case *secrets.LocalSecretStore: + for k, _ := range s.Secrets { + if creds, err := bmc.GetBMCCredentials(store, k); err != nil { + log.Error().Str("id", k).Err(err).Msg("failed to override BMC credentials") + } else { + if username != "" { + creds.Username = username + } + if password != "" { + creds.Password = password + } + + if newCreds, err := json.Marshal(creds); err != nil { + log.Error().Str("id", k).Err(err).Msg("failed to override BMC credentials: marshal error") + } else { + s.StoreSecretByID(k, string(newCreds)) + } + } + } + } + } + + // set the collect parameters from CLI params + params := &magellan.CollectParams{ URI: host, - Username: username, - Password: password, Timeout: timeout, Concurrency: concurrency, Verbose: verbose, @@ -66,31 +124,35 @@ var CollectCmd = &cobra.Command{ OutputPath: outputPath, ForceUpdate: forceUpdate, AccessToken: accessToken, - }) + SecretStore: store, + } + + // show all of the 'collect' parameters being set from CLI if verbose + if verbose { + log.Debug().Any("params", params) + } + + _, err = magellan.CollectInventory(&scannedResults, params) if err != nil { - log.Error().Err(err).Msgf("failed to collect data") + log.Error().Err(err).Msg("failed to collect data") } }, } func init() { currentUser, _ = user.Current() - CollectCmd.PersistentFlags().StringVar(&host, "host", "", "Set the URI to the SMD root endpoint") - CollectCmd.PersistentFlags().StringVar(&username, "username", "", "Set the BMC user") - CollectCmd.PersistentFlags().StringVar(&password, "password", "", "Set the BMC password") - CollectCmd.PersistentFlags().StringVar(&scheme, "scheme", "https", "Set the scheme used to query") - CollectCmd.PersistentFlags().StringVar(&protocol, "protocol", "tcp", "Set the protocol used to query") - CollectCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", fmt.Sprintf("/tmp/%smagellan/inventory/", currentUser.Username+"/"), "Set the path to store collection data") - CollectCmd.PersistentFlags().BoolVar(&forceUpdate, "force-update", false, "Set flag to force update data sent to SMD") - CollectCmd.PersistentFlags().StringVar(&cacertPath, "cacert", "", "Path to CA cert. (defaults to system CAs)") - - // set flags to only be used together - CollectCmd.MarkFlagsRequiredTogether("username", "password") + CollectCmd.Flags().StringVar(&host, "host", "", "Set the URI to the SMD root endpoint") + CollectCmd.Flags().StringVarP(&username, "username", "u", "", "Set the master BMC username") + CollectCmd.Flags().StringVarP(&password, "password", "p", "", "Set the master BMC password") + CollectCmd.Flags().StringVar(&secretsFile, "secrets-file", "", "Set path to the node secrets file") + CollectCmd.Flags().StringVar(&scheme, "scheme", "https", "Set the default scheme used to query when not included in URI") + CollectCmd.Flags().StringVar(&protocol, "protocol", "tcp", "Set the protocol used to query") + CollectCmd.Flags().StringVarP(&outputPath, "output", "o", fmt.Sprintf("/tmp/%smagellan/inventory/", currentUser.Username+"/"), "Set the path to store collection data") + CollectCmd.Flags().BoolVar(&forceUpdate, "force-update", false, "Set flag to force update data sent to SMD") + CollectCmd.Flags().StringVar(&cacertPath, "cacert", "", "Set the path to CA cert file. (defaults to system CAs when blank)") // bind flags to config properties checkBindFlagError(viper.BindPFlag("collect.host", CollectCmd.Flags().Lookup("host"))) - checkBindFlagError(viper.BindPFlag("collect.username", CollectCmd.Flags().Lookup("username"))) - checkBindFlagError(viper.BindPFlag("collect.password", CollectCmd.Flags().Lookup("password"))) checkBindFlagError(viper.BindPFlag("collect.scheme", CollectCmd.Flags().Lookup("scheme"))) checkBindFlagError(viper.BindPFlag("collect.protocol", CollectCmd.Flags().Lookup("protocol"))) checkBindFlagError(viper.BindPFlag("collect.output", CollectCmd.Flags().Lookup("output"))) diff --git a/cmd/crawl.go b/cmd/crawl.go index a5bb56c..c1acc3a 100644 --- a/cmd/crawl.go +++ b/cmd/crawl.go @@ -3,10 +3,13 @@ package cmd import ( "encoding/json" "fmt" - "log" + + "github.com/rs/zerolog/log" urlx "github.com/davidallendj/magellan/internal/url" + "github.com/davidallendj/magellan/pkg/bmc" "github.com/davidallendj/magellan/pkg/crawler" + "github.com/davidallendj/magellan/pkg/secrets" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -15,13 +18,11 @@ import ( // specfic inventory detail. This command only expects host names and does // not require a scan to be performed beforehand. var CrawlCmd = &cobra.Command{ - Use: "crawl [uri]", + Use: "crawl [uri]", + Example: ` magellan crawl https://bmc.example.com + magellan crawl https://bmc.example.com -i -u username -p password`, Short: "Crawl a single BMC for inventory information", - Long: "Crawl a single BMC for inventory information. This command does NOT store information\n" + - "about the scan into cache after completion. To do so, use the 'collect' command instead\n\n" + - "Examples:\n" + - " magellan crawl https://bmc.example.com\n" + - " magellan crawl https://bmc.example.com -i -u username -p password", + Long: "Crawl a single BMC for inventory information with URI.\n\n NOTE: This command does not scan subnets, store scan information in cache, nor make a request to a specified host. It is used only to retrieve inventory data directly. Otherwise, use 'scan' and 'collect' instead.", Args: func(cmd *cobra.Command, args []string) error { // Validate that the only argument is a valid URI var err error @@ -35,19 +36,59 @@ var CrawlCmd = &cobra.Command{ return nil }, Run: func(cmd *cobra.Command, args []string) { + var ( + uri = args[0] + store secrets.SecretStore + err error + ) + + if username != "" && password != "" { + // First, try and load credentials from --username and --password if both are set. + log.Debug().Str("id", uri).Msgf("--username and --password specified, using them for BMC credentials") + store = secrets.NewStaticStore(username, password) + } else { + // Alternatively, locate specific credentials (falling back to default) and override those + // with --username or --password if either are passed. + log.Debug().Str("id", uri).Msgf("one or both of --username and --password NOT passed, attempting to obtain missing credentials from secret store at %s", secretsFile) + if store, err = secrets.OpenStore(secretsFile); err != nil { + log.Error().Str("id", uri).Err(err).Msg("failed to open local secrets store") + } + + // Either none of the flags were passed or only one of them were; get + // credentials from secrets store to fill in the gaps. + bmcCreds, _ := bmc.GetBMCCredentials(store, uri) + nodeCreds := secrets.StaticStore{ + Username: bmcCreds.Username, + Password: bmcCreds.Password, + } + + // If either of the flags were passed, override the fetched + // credentials with them. + if username != "" { + log.Info().Str("id", uri).Msg("--username was set, overriding username for this BMC") + nodeCreds.Username = username + } + if password != "" { + log.Info().Str("id", uri).Msg("--password was set, overriding password for this BMC") + nodeCreds.Password = password + } + + store = &nodeCreds + } + systems, err := crawler.CrawlBMCForSystems(crawler.CrawlerConfig{ - URI: args[0], - Username: cmd.Flag("username").Value.String(), - Password: cmd.Flag("password").Value.String(), - Insecure: cmd.Flag("insecure").Value.String() == "true", + URI: uri, + CredentialStore: store, + Insecure: insecure, + UseDefault: true, }) if err != nil { - log.Fatalf("Error crawling BMC: %v", err) + log.Error().Err(err).Msg("failed to crawl BMC") } // Marshal the inventory details to JSON jsonData, err := json.MarshalIndent(systems, "", " ") if err != nil { - fmt.Println("Error marshalling to JSON:", err) + log.Error().Err(err).Msg("failed to marshal JSON") return } @@ -57,12 +98,11 @@ var CrawlCmd = &cobra.Command{ } func init() { - CrawlCmd.Flags().StringP("username", "u", "", "Set the username for the BMC") - CrawlCmd.Flags().StringP("password", "p", "", "Set the password for the BMC") - CrawlCmd.Flags().BoolP("insecure", "i", false, "Ignore SSL errors") + CrawlCmd.Flags().StringVarP(&username, "username", "u", "", "Set the username for the BMC") + CrawlCmd.Flags().StringVarP(&password, "password", "p", "", "Set the password for the BMC") + CrawlCmd.Flags().BoolVarP(&insecure, "insecure", "i", false, "Ignore SSL errors") + CrawlCmd.Flags().StringVarP(&secretsFile, "file", "f", "nodes.json", "set the secrets file with BMC credentials") - checkBindFlagError(viper.BindPFlag("crawl.username", CrawlCmd.Flags().Lookup("username"))) - checkBindFlagError(viper.BindPFlag("crawl.password", CrawlCmd.Flags().Lookup("password"))) checkBindFlagError(viper.BindPFlag("crawl.insecure", CrawlCmd.Flags().Lookup("insecure"))) rootCmd.AddCommand(CrawlCmd) diff --git a/cmd/list.go b/cmd/list.go index 24945ae..7d5b781 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -20,13 +20,15 @@ var ( // and stored in a cache database from a scan. The data that's stored // is what is consumed by the `collect` command with the --cache flag. var ListCmd = &cobra.Command{ - Use: "list", + Use: "list", + Example: ` magellan list + magellan list --cache ./assets.db + magellan list --cache-info + `, + Args: cobra.ExactArgs(0), Short: "List information stored in cache from a scan", Long: "Prints all of the host and associated data found from performing a scan.\n" + - "See the 'scan' command on how to perform a scan.\n\n" + - "Examples:\n" + - " magellan list\n" + - " magellan list --cache ./assets.db", + "See the 'scan' command on how to perform a scan.", Run: func(cmd *cobra.Command, args []string) { // check if we just want to show cache-related info and exit if showCache { diff --git a/cmd/root.go b/cmd/root.go index 778e37f..fc15150 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -44,6 +44,7 @@ var ( verbose bool debug bool forceUpdate bool + insecure bool ) // The `root` command doesn't do anything on it's own except display @@ -51,7 +52,7 @@ var ( var rootCmd = &cobra.Command{ Use: "magellan", Short: "Redfish-based BMC discovery tool", - Long: "", + Long: "Redfish-based BMC discovery tool with dynamic discovery features.", Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { err := cmd.Help() @@ -74,8 +75,8 @@ func Execute() { func init() { currentUser, _ = user.Current() cobra.OnInitialize(InitializeConfig) - rootCmd.PersistentFlags().IntVar(&concurrency, "concurrency", -1, "Set the number of concurrent processes") - rootCmd.PersistentFlags().IntVar(&timeout, "timeout", 5, "Set the timeout for requests") + rootCmd.PersistentFlags().IntVarP(&concurrency, "concurrency", "j", -1, "Set the number of concurrent processes") + rootCmd.PersistentFlags().IntVarP(&timeout, "timeout", "t", 5, "Set the timeout for requests") rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "Set the config file path") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Set to enable/disable verbose output") rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Set to enable/disable debug messages") @@ -93,7 +94,7 @@ func init() { func checkBindFlagError(err error) { if err != nil { - log.Error().Err(err).Msg("failed to bind flag") + log.Error().Err(err).Msg("failed to bind cobra/viper flag") } } diff --git a/cmd/scan.go b/cmd/scan.go index d2cbaf8..f25150d 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -7,8 +7,8 @@ import ( "os" "path" - magellan "github.com/davidallendj/magellan/internal" "github.com/davidallendj/magellan/internal/cache/sqlite" + magellan "github.com/davidallendj/magellan/pkg" "github.com/rs/zerolog/log" "github.com/cznic/mathutil" @@ -33,7 +33,28 @@ var ( // See the `ScanForAssets()` function in 'internal/scan.go' for details // related to the implementation. var ScanCmd = &cobra.Command{ - Use: "scan urls...", + Use: "scan urls...", + Example: ` + // assumes host https://10.0.0.101:443 + magellan scan 10.0.0.101 + + // assumes subnet using HTTPS and port 443 except for specified host + magellan scan http://10.0.0.101:80 https://user:password@10.0.0.102:443 http://172.16.0.105:8080 --subnet 172.16.0.0/24 + + // assumes hosts http://10.0.0.101:8080 and http://10.0.0.102:8080 + magellan scan 10.0.0.101 10.0.0.102 https://172.16.0.10:443 --port 8080 --protocol tcp + + // assumes subnet using default unspecified subnet-masks + magellan scan --subnet 10.0.0.0 + + // assumes subnet using HTTPS and port 443 with specified CIDR + magellan scan --subnet 10.0.0.0/16 + + // assumes subnet using HTTP and port 5000 similar to 192.168.0.0/16 + magellan scan --subnet 192.168.0.0 --protocol tcp --scheme https --port 5000 --subnet-mask 255.255.0.0 + + // assumes subnet without CIDR has a subnet-mask of 255.255.0.0 + magellan scan --subnet 10.0.0.0/24 --subnet 172.16.0.0 --subnet-mask 255.255.0.0 --cache ./assets.db`, Short: "Scan to discover BMC nodes on a network", Long: "Perform a net scan by attempting to connect to each host and port specified and getting a response.\n" + "Each host is passed *with a full URL* including the protocol and port. Additional subnets can be added\n" + @@ -46,22 +67,7 @@ var ScanCmd = &cobra.Command{ "'--protocol' flag.\n\n" + "If the '--disable-probe` flag is used, the tool will not send another request to probe for available.\n" + "Redfish services. This is not recommended, since the extra request makes the scan a bit more reliable\n" + - "for determining which hosts to collect inventory data.\n\n" + - "Examples:\n" + - // assumes host https://10.0.0.101:443 - " magellan scan 10.0.0.101\n" + - // assumes subnet using HTTPS and port 443 except for specified host - " magellan scan http://10.0.0.101:80 https://user:password@10.0.0.102:443 http://172.16.0.105:8080 --subnet 172.16.0.0/24\n" + - // assumes hosts http://10.0.0.101:8080 and http://10.0.0.102:8080 - " magellan scan 10.0.0.101 10.0.0.102 https://172.16.0.10:443 --port 8080 --protocol tcp\n" + - // assumes subnet using default unspecified subnet-masks - " magellan scan --subnet 10.0.0.0\n" + - // assumes subnet using HTTPS and port 443 with specified CIDR - " magellan scan --subnet 10.0.0.0/16\n" + - // assumes subnet using HTTP and port 5000 similar to 192.168.0.0/16 - " magellan scan --subnet 192.168.0.0 --protocol tcp --scheme https --port 5000 --subnet-mask 255.255.0.0\n" + - // assumes subnet without CIDR has a subnet-mask of 255.255.0.0 - " magellan scan --subnet 10.0.0.0/24 --subnet 172.16.0.0 --subnet-mask 255.255.0.0 --cache ./assets.db\n", + "for determining which hosts to collect inventory data.\n\n", Run: func(cmd *cobra.Command, args []string) { // add default ports for hosts if none are specified with flag if len(ports) == 0 { diff --git a/cmd/secrets.go b/cmd/secrets.go new file mode 100644 index 0000000..59523e6 --- /dev/null +++ b/cmd/secrets.go @@ -0,0 +1,277 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/davidallendj/magellan/pkg/secrets" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + secretsFile string + secretsStoreFormat string + secretsStoreInputFile string +) + +var secretsCmd = &cobra.Command{ + Use: "secrets", + Example: ` + // generate new key and set environment variable + export MASTER_KEY=$(magellan secrets generatekey) + + // store specific BMC node creds for collect and crawl in default secrets store (--file/-f flag not set) + magellan secrets store $bmc_host $bmc_creds + + // retrieve creds from secrets store + magellan secrets retrieve $bmc_host -f nodes.json + + // list creds from specific secrets + magellan secrets list -f nodes.json`, + Short: "Manage credentials for BMC nodes", + Long: "Manage credentials for BMC nodes to for querying information through redfish. This requires generating a key and setting the 'MASTER_KEY' environment variable for the secrets store.", + Run: func(cmd *cobra.Command, args []string) { + // show command help and exit + if len(args) < 1 { + cmd.Help() + os.Exit(0) + } + }, +} + +var secretsGenerateKeyCmd = &cobra.Command{ + Use: "generatekey", + Args: cobra.NoArgs, + Short: "Generates a new 32-byte master key (in hex).", + Run: func(cmd *cobra.Command, args []string) { + key, err := secrets.GenerateMasterKey() + if err != nil { + fmt.Printf("Error generating master key: %v\n", err) + os.Exit(1) + } + fmt.Printf("%s\n", key) + }, +} + +var secretsStoreCmd = &cobra.Command{ + Use: "store secretID ", + Args: cobra.MinimumNArgs(1), + Short: "Stores the given string value under secretID.", + Run: func(cmd *cobra.Command, args []string) { + var ( + secretID = args[0] + secretValue string + store secrets.SecretStore + inputFileBytes []byte + err error + ) + + // require either the args or input file + if len(args) < 1 && secretsStoreInputFile == "" { + log.Error().Msg("no input data or file") + os.Exit(1) + } else if len(args) > 1 && secretsStoreInputFile == "" { + // use args[1] here because args[0] is the secretID + secretValue = args[1] + } + + // handle input file format + switch secretsStoreFormat { + case "basic": // format: $username:$password + var ( + values []string + username string + password string + ) + // seperate username and password provided + values = strings.Split(secretValue, ":") + if len(values) != 2 { + log.Error().Msgf("expected 2 arguments in [username:password] format but got %d", len(values)) + os.Exit(1) + } + + // open secret store to save credentials + store, err = secrets.OpenStore(secretsFile) + if err != nil { + log.Error().Err(err).Msg("failed to open secrets store") + os.Exit(1) + } + + // extract username/password from input (for clarity) + username = values[0] + password = values[1] + + // create JSON formatted string from input + secretValue = fmt.Sprintf("{\"username\": \"%s\", \"password\": \"%s\"}", username, password) + + case "base64": // format: ($encoded_base64_string) + decoded, err := base64.StdEncoding.DecodeString(secretValue) + if err != nil { + log.Error().Err(err).Msg("error decoding base64 data") + os.Exit(1) + } + + // check the decoded string if it's a valid JSON and has creds + if !isValidCredsJSON(string(decoded)) { + log.Error().Err(err).Msg("value is not a valid JSON or is missing credentials") + os.Exit(1) + } + + store, err = secrets.OpenStore(secretsFile) + if err != nil { + log.Error().Err(err).Msg("failed to open secrets store") + os.Exit(1) + } + secretValue = string(decoded) + case "json": // format: {"username": $username, "password": $password} + // read input from file if set and override + if secretsStoreInputFile != "" { + if secretValue != "" { + log.Error().Msg("cannot use -i/--input-file with positional argument") + os.Exit(1) + } + inputFileBytes, err = os.ReadFile(secretsStoreInputFile) + if err != nil { + log.Error().Err(err).Msg("failed to read input file") + os.Exit(1) + } + secretValue = string(inputFileBytes) + } + + // make sure we have valid JSON with "username" and "password" properties + if !isValidCredsJSON(string(secretValue)) { + log.Error().Err(err).Msg("not a valid JSON or creds") + os.Exit(1) + } + store, err = secrets.OpenStore(secretsFile) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + default: + log.Error().Msg("no input format set") + os.Exit(1) + } + + if err := store.StoreSecretByID(secretID, secretValue); err != nil { + fmt.Printf("Error storing secret: %v\n", err) + os.Exit(1) + } + }, +} + +func isValidCredsJSON(val string) bool { + var ( + valid = !json.Valid([]byte(val)) + creds map[string]string + err error + ) + err = json.Unmarshal([]byte(val), &creds) + if err != nil { + return false + } + _, valid = creds["username"] + _, valid = creds["password"] + return valid +} + +var secretsRetrieveCmd = &cobra.Command{ + Use: "retrieve secretID", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + var ( + secretID = args[0] + secretValue string + store secrets.SecretStore + err error + ) + + store, err = secrets.OpenStore(secretsFile) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + secretValue, err = store.GetSecretByID(secretID) + if err != nil { + fmt.Printf("Error retrieving secret: %v\n", err) + os.Exit(1) + } + fmt.Printf("Secret for %s: %s\n", secretID, secretValue) + }, +} + +var secretsListCmd = &cobra.Command{ + Use: "list", + Args: cobra.ExactArgs(0), + Short: "Lists all the secret IDs and their values.", + Run: func(cmd *cobra.Command, args []string) { + store, err := secrets.OpenStore(secretsFile) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + secrets, err := store.ListSecrets() + if err != nil { + fmt.Printf("Error listing secrets: %v\n", err) + os.Exit(1) + } + + for key, value := range secrets { + fmt.Printf("%s: %s\n", key, value) + } + }, +} + +var secretsRemoveCmd = &cobra.Command{ + Use: "remove secretIDs...", + Args: cobra.MinimumNArgs(1), + Short: "Remove secrets by IDs from secret store.", + Run: func(cmd *cobra.Command, args []string) { + for _, secretID := range args { + // open secret store from file + store, err := secrets.OpenStore(secretsFile) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // remove secret from store by it's ID + err = store.RemoveSecretByID(secretID) + if err != nil { + fmt.Println("failed to remove secret: ", err) + os.Exit(1) + } + + // update store by saving to original file + secrets.SaveSecrets(secretsFile, store.(*secrets.LocalSecretStore).Secrets) + } + }, +} + +func init() { + secretsCmd.Flags().StringVarP(&secretsFile, "file", "f", "nodes.json", "set the secrets file with BMC credentials") + secretsStoreCmd.Flags().StringVarP(&secretsStoreFormat, "format", "F", "basic", "set the input format for the secrets file (basic|json|base64)") + secretsStoreCmd.Flags().StringVarP(&secretsStoreInputFile, "input-file", "i", "", "set the file to read as input") + + secretsCmd.AddCommand(secretsGenerateKeyCmd) + secretsCmd.AddCommand(secretsStoreCmd) + secretsCmd.AddCommand(secretsRetrieveCmd) + secretsCmd.AddCommand(secretsListCmd) + secretsCmd.AddCommand(secretsRemoveCmd) + + rootCmd.AddCommand(secretsCmd) + + checkBindFlagError(viper.BindPFlags(secretsCmd.Flags())) + checkBindFlagError(viper.BindPFlags(secretsGenerateKeyCmd.Flags())) + checkBindFlagError(viper.BindPFlags(secretsStoreCmd.Flags())) + checkBindFlagError(viper.BindPFlags(secretsGenerateKeyCmd.Flags())) + checkBindFlagError(viper.BindPFlags(secretsGenerateKeyCmd.Flags())) + checkBindFlagError(viper.BindPFlags(secretsGenerateKeyCmd.Flags())) +} diff --git a/cmd/update.go b/cmd/update.go index d0b050c..c2cc3a4 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -4,7 +4,9 @@ import ( "os" "strings" - magellan "github.com/davidallendj/magellan/internal" + magellan "github.com/davidallendj/magellan/pkg" + "github.com/davidallendj/magellan/pkg/bmc" + "github.com/davidallendj/magellan/pkg/secrets" "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -12,23 +14,26 @@ import ( var ( host string - firmwareUrl string - firmwareVersion string - component string + firmwareUri string transferProtocol string showStatus bool + Insecure bool ) // The `update` command provides an interface to easily update firmware // using Redfish. It also provides a simple way to check the status of // an update in-progress. var UpdateCmd = &cobra.Command{ - Use: "update hosts...", + Use: "update hosts...", + Example: ` // perform an firmware update + magellan update 172.16.0.108:443 -i -u $bmc_username -p $bmc_password \ + --firmware-url http://172.16.0.200:8005/firmware/bios/image.RBU \ + --component BIOS + + // check update status + magellan update 172.16.0.108:443 -i -u $bmc_username -p $bmc_password --status`, Short: "Update BMC node firmware", - Long: "Perform an firmware update using Redfish by providing a remote firmware URL and component.\n\n" + - "Examples:\n" + - " magellan update 172.16.0.108:443 --username bmc_username --password bmc_password --firmware-url http://172.16.0.200:8005/firmware/bios/image.RBU --component BIOS\n" + - " magellan update 172.16.0.108:443 --status --username bmc_username --password bmc_password", + Long: "Perform an firmware update using Redfish by providing a remote firmware URL and component.", Run: func(cmd *cobra.Command, args []string) { // check that we have at least one host if len(args) <= 0 { @@ -36,19 +41,57 @@ var UpdateCmd = &cobra.Command{ os.Exit(1) } + // use secret store for BMC credentials, and/or credential CLI flags + var ( + store secrets.SecretStore + uri = args[0] + err error + ) + if username != "" && password != "" { + // First, try and load credentials from --username and --password if both are set. + log.Debug().Str("id", uri).Msgf("--username and --password specified, using them for BMC credentials") + store = secrets.NewStaticStore(username, password) + } else { + // Alternatively, locate specific credentials (falling back to default) and override those + // with --username or --password if either are passed. + log.Debug().Str("id", uri).Msgf("one or both of --username and --password NOT passed, attempting to obtain missing credentials from secret store at %s", secretsFile) + if store, err = secrets.OpenStore(secretsFile); err != nil { + log.Error().Str("id", uri).Err(err).Msg("failed to open local secrets store") + } + + // Either none of the flags were passed or only one of them were; get + // credentials from secrets store to fill in the gaps. + bmcCreds, _ := bmc.GetBMCCredentials(store, uri) + nodeCreds := secrets.StaticStore{ + Username: bmcCreds.Username, + Password: bmcCreds.Password, + } + + // If either of the flags were passed, override the fetched + // credentials with them. + if username != "" { + log.Info().Str("id", uri).Msg("--username was set, overriding username for this BMC") + nodeCreds.Username = username + } + if password != "" { + log.Info().Str("id", uri).Msg("--password was set, overriding password for this BMC") + nodeCreds.Password = password + } + + store = &nodeCreds + } + // get status if flag is set and exit for _, arg := range args { if showStatus { err := magellan.GetUpdateStatus(&magellan.UpdateParams{ - FirmwarePath: firmwareUrl, - FirmwareVersion: firmwareVersion, - Component: component, + FirmwareURI: firmwareUri, TransferProtocol: transferProtocol, + Insecure: Insecure, CollectParams: magellan.CollectParams{ - URI: arg, - Username: username, - Password: password, - Timeout: timeout, + URI: arg, + SecretStore: store, + Timeout: timeout, }, }) if err != nil { @@ -59,15 +102,13 @@ var UpdateCmd = &cobra.Command{ // initiate a remote update err := magellan.UpdateFirmwareRemote(&magellan.UpdateParams{ - FirmwarePath: firmwareUrl, - FirmwareVersion: firmwareVersion, - Component: component, + FirmwareURI: firmwareUri, TransferProtocol: strings.ToUpper(transferProtocol), + Insecure: Insecure, CollectParams: magellan.CollectParams{ - URI: host, - Username: username, - Password: password, - Timeout: timeout, + URI: arg, + SecretStore: store, + Timeout: timeout, }, }) if err != nil { @@ -81,9 +122,7 @@ func init() { UpdateCmd.Flags().StringVar(&username, "username", "", "Set the BMC user") UpdateCmd.Flags().StringVar(&password, "password", "", "Set the BMC password") UpdateCmd.Flags().StringVar(&transferProtocol, "scheme", "https", "Set the transfer protocol") - UpdateCmd.Flags().StringVar(&firmwareUrl, "firmware-url", "", "Set the path to the firmware") - UpdateCmd.Flags().StringVar(&firmwareVersion, "firmware-version", "", "Set the version of firmware to be installed") - UpdateCmd.Flags().StringVar(&component, "component", "", "Set the component to upgrade (BMC|BIOS)") + UpdateCmd.Flags().StringVar(&firmwareUri, "firmware-uri", "", "Set the path to the firmware") UpdateCmd.Flags().BoolVar(&showStatus, "status", false, "Get the status of the update") checkBindFlagError(viper.BindPFlag("update.username", UpdateCmd.Flags().Lookup("username"))) diff --git a/emulator/rf-emulator.yml b/emulator/rf-emulator.yml index eec8191..4b399ea 100644 --- a/emulator/rf-emulator.yml +++ b/emulator/rf-emulator.yml @@ -6,7 +6,7 @@ volumes: services: emulator: - image: davidallendj-rie:latest + image: ghcr.io/openchami/csm-rie:latest container_name: rf-emulator environment: BMC_PORT: 5000 diff --git a/go.mod b/go.mod index 1a2e948..1224cc7 100644 --- a/go.mod +++ b/go.mod @@ -16,10 +16,12 @@ require ( golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 ) -require github.com/rs/zerolog v1.33.0 +require ( + github.com/rs/zerolog v1.33.0 + golang.org/x/crypto v0.32.0 +) require ( - github.com/google/go-cmp v0.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect ) @@ -28,6 +30,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect @@ -49,9 +52,8 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.22.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index befbed6..be886fb 100644 --- a/go.sum +++ b/go.sum @@ -123,8 +123,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc= golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -151,8 +151,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -164,8 +164,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/cache/sqlite/sqlite.go b/internal/cache/sqlite/sqlite.go index fa0ae0b..bbbdff3 100644 --- a/internal/cache/sqlite/sqlite.go +++ b/internal/cache/sqlite/sqlite.go @@ -2,10 +2,9 @@ package sqlite import ( "fmt" - "strings" - magellan "github.com/davidallendj/magellan/internal" "github.com/davidallendj/magellan/internal/util" + magellan "github.com/davidallendj/magellan/pkg" "github.com/jmoiron/sqlx" ) @@ -28,7 +27,10 @@ func CreateScannedAssetIfNotExists(path string) (*sqlx.DB, error) { if err != nil { return nil, fmt.Errorf("failed to open database: %v", err) } - db.MustExec(schema) + _, err = db.Exec(schema) + if err != nil { + return nil, fmt.Errorf("failed to create scanned assets cache: %v", err) + } return db, nil } @@ -75,22 +77,7 @@ func DeleteScannedAssets(path string, assets ...magellan.RemoteAsset) error { } tx = db.MustBegin() for _, asset := range assets { - // skip if neither host nor port are specified - if asset.Host == "" && asset.Port <= 0 { - continue - } - sql := fmt.Sprintf(`DELETE FROM %s`, TABLE_NAME) - where := []string{} - if asset.Port > 0 { - where = append(where, "port=:port") - } - if asset.Host != "" { - where = append(where, "host=:host") - } - if len(where) <= 0 { - continue - } - sql += fmt.Sprintf(" WHERE %s;", strings.Join(where, " AND ")) + sql := fmt.Sprintf(`DELETE FROM %s WHERE host=:host AND port=:port;`, TABLE_NAME) _, err := tx.NamedExec(sql, &asset) if err != nil { fmt.Printf("failed to execute DELETE transaction: %v\n", err) diff --git a/internal/update.go b/internal/update.go deleted file mode 100644 index 464b0b9..0000000 --- a/internal/update.go +++ /dev/null @@ -1,78 +0,0 @@ -package magellan - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/davidallendj/magellan/pkg/client" -) - -type UpdateParams struct { - CollectParams - FirmwarePath string - FirmwareVersion string - Component string - TransferProtocol string -} - -// UpdateFirmwareRemote() uses 'gofish' to update the firmware of a BMC node. -// The function expects the firmware URL, firmware version, and component flags to be -// set from the CLI to perform a firmware update. -func UpdateFirmwareRemote(q *UpdateParams) error { - // parse URI to set up full address - uri, err := url.ParseRequestURI(q.URI) - if err != nil { - return fmt.Errorf("failed to parse URI: %w", err) - } - uri.User = url.UserPassword(q.Username, q.Password) - - // set up other vars - updateUrl := fmt.Sprintf("%s/redfish/v1/UpdateService/Actions/SimpleUpdate", uri.String()) - headers := map[string]string{ - "Content-Type": "application/json", - "cache-control": "no-cache", - } - b := map[string]any{ - "UpdateComponent": q.Component, // BMC, BIOS - "TransferProtocol": q.TransferProtocol, - "ImageURI": q.FirmwarePath, - } - data, err := json.Marshal(b) - if err != nil { - return fmt.Errorf("failed to marshal data: %v", err) - } - res, body, err := client.MakeRequest(nil, updateUrl, "POST", data, headers) - if err != nil { - return fmt.Errorf("something went wrong: %v", err) - } else if res == nil { - return fmt.Errorf("no response returned (url: %s)", updateUrl) - } - if len(body) > 0 { - fmt.Printf("%d: %v\n", res.StatusCode, string(body)) - } - return nil -} - -func GetUpdateStatus(q *UpdateParams) error { - // parse URI to set up full address - uri, err := url.ParseRequestURI(q.URI) - if err != nil { - return fmt.Errorf("failed to parse URI: %w", err) - } - uri.User = url.UserPassword(q.Username, q.Password) - updateUrl := fmt.Sprintf("%s/redfish/v1/UpdateService", uri.String()) - res, body, err := client.MakeRequest(nil, updateUrl, "GET", nil, nil) - if err != nil { - return fmt.Errorf("something went wrong: %v", err) - } else if res == nil { - return fmt.Errorf("no response returned (url: %s)", updateUrl) - } else if res.StatusCode != http.StatusOK { - return fmt.Errorf("returned status code %d", res.StatusCode) - } - if len(body) > 0 { - fmt.Printf("%v\n", string(body)) - } - return nil -} diff --git a/internal/util/bmc.go b/internal/util/bmc.go new file mode 100644 index 0000000..d2aaebf --- /dev/null +++ b/internal/util/bmc.go @@ -0,0 +1,47 @@ +package util + +import ( + "github.com/davidallendj/magellan/pkg/bmc" + "github.com/davidallendj/magellan/pkg/secrets" + "github.com/rs/zerolog/log" +) + +func GetBMCCredentials(store secrets.SecretStore, id string) bmc.BMCCredentials { + var ( + creds bmc.BMCCredentials + err error + ) + + if id == "" { + log.Error().Msg("failed to get BMC credentials: id was empty") + return creds + } + + if id == secrets.DEFAULT_KEY { + log.Info().Msg("fetching default credentials") + if creds, err = bmc.GetBMCCredentialsDefault(store); err != nil { + log.Warn().Err(err).Msg("failed to get default credentials") + } else { + log.Info().Msg("default credentials found, using") + } + return creds + } + + if creds, err = bmc.GetBMCCredentials(store, id); err != nil { + // Specific credentials for URI not found, fetch default. + log.Warn().Str("id", id).Msg("specific credentials not found, falling back to default") + if defaultSecret, err := bmc.GetBMCCredentialsDefault(store); err != nil { + // We've exhausted all options, the credentials will be blank unless + // overridden by a CLI flag. + log.Warn().Str("id", id).Err(err).Msg("no default credentials were set, they will be blank unless overridden by CLI flags") + } else { + // Default credentials found, use them. + log.Info().Str("id", id).Msg("default credentials found, using") + creds = defaultSecret + } + } else { + log.Info().Str("id", id).Msg("specific credentials found, using") + } + + return creds +} diff --git a/pkg/bmc/bmc.go b/pkg/bmc/bmc.go new file mode 100644 index 0000000..90dcadf --- /dev/null +++ b/pkg/bmc/bmc.go @@ -0,0 +1,65 @@ +package bmc + +import ( + "encoding/json" + "fmt" + + "github.com/davidallendj/magellan/pkg/secrets" +) + +type BMCCredentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func GetBMCCredentialsDefault(store secrets.SecretStore) (BMCCredentials, error) { + var creds BMCCredentials + if strCreds, err := store.GetSecretByID(secrets.DEFAULT_KEY); err != nil { + return creds, fmt.Errorf("get default BMC credentials from secret store: %w", err) + } else { + // Default URI credentials found, use them. + if err = json.Unmarshal([]byte(strCreds), &creds); err != nil { + return creds, fmt.Errorf("get default BMC credentials from secret store: failed to unmarshal: %w", err) + } + return creds, nil + } +} + +func GetBMCCredentials(store secrets.SecretStore, id string) (BMCCredentials, error) { + var creds BMCCredentials + if strCreds, err := store.GetSecretByID(id); err != nil { + return creds, fmt.Errorf("get BMC credentials from secret store: %w", err) + } else { + // Specific URI credentials found, use them. + if err = json.Unmarshal([]byte(strCreds), &creds); err != nil { + return creds, fmt.Errorf("get BMC credentials from secret store: failed to unmarshal: %w", err) + } + } + + return creds, nil +} + +func GetBMCCredentialsOrDefault(store secrets.SecretStore, id string) BMCCredentials { + var ( + creds BMCCredentials + err error + ) + + if id == "" { + return creds + } + + if id == secrets.DEFAULT_KEY { + creds, _ = GetBMCCredentialsDefault(store) + return creds + } + + if creds, err = GetBMCCredentials(store, id); err != nil { + if defaultSecret, err := GetBMCCredentialsDefault(store); err == nil { + // Default credentials found, use them. + creds = defaultSecret + } + } + + return creds +} diff --git a/internal/collect.go b/pkg/collect.go similarity index 77% rename from internal/collect.go rename to pkg/collect.go index 2f2c863..d5b8855 100644 --- a/internal/collect.go +++ b/pkg/collect.go @@ -15,8 +15,10 @@ import ( "sync" "time" + "github.com/davidallendj/magellan/pkg/bmc" "github.com/davidallendj/magellan/pkg/client" "github.com/davidallendj/magellan/pkg/crawler" + "github.com/davidallendj/magellan/pkg/secrets" "github.com/rs/zerolog/log" @@ -30,16 +32,15 @@ import ( // CollectParams is a collection of common parameters passed to the CLI // for the 'collect' subcommand. type CollectParams struct { - URI string // set by the 'host' flag - Username string // set the BMC username with the 'username' flag - Password string // set the BMC password with the 'password' flag - Concurrency int // set the of concurrent jobs with the 'concurrency' flag - Timeout int // set the timeout with the 'timeout' flag - CaCertPath string // set the cert path with the 'cacert' flag - Verbose bool // set whether to include verbose output with 'verbose' flag - OutputPath string // set the path to save output with 'output' flag - ForceUpdate bool // set whether to force updating SMD with 'force-update' flag - AccessToken string // set the access token to include in request with 'access-token' flag + URI string // set by the 'host' flag + Concurrency int // set the of concurrent jobs with the 'concurrency' flag + Timeout int // set the timeout with the 'timeout' flag + CaCertPath string // set the cert path with the 'cacert' flag + Verbose bool // set whether to include verbose output with 'verbose' flag + OutputPath string // set the path to save output with 'output' flag + ForceUpdate bool // set whether to force updating SMD with 'force-update' flag + AccessToken string // set the access token to include in request with 'access-token' flag + SecretStore secrets.SecretStore // set BMC credentials } // This is the main function used to collect information from the BMC nodes via Redfish. @@ -48,36 +49,38 @@ type CollectParams struct { // // Requests can be made to several of the nodes using a goroutine by setting the q.Concurrency // property value between 1 and 10000. -func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { +func CollectInventory(assets *[]RemoteAsset, params *CollectParams) ([]map[string]any, error) { // check for available remote assets found from scan if assets == nil { - return fmt.Errorf("no assets found") + return nil, fmt.Errorf("no assets found") } if len(*assets) <= 0 { - return fmt.Errorf("no assets found") + return nil, fmt.Errorf("no assets found") } // collect bmc information asynchronously var ( offset = 0 wg sync.WaitGroup + collection = make([]map[string]any, 0) found = make([]string, 0, len(*assets)) done = make(chan struct{}, params.Concurrency+1) chanAssets = make(chan RemoteAsset, params.Concurrency+1) outputPath = path.Clean(params.OutputPath) smdClient = &client.SmdClient{Client: &http.Client{}} ) + // set the client's params from CLI // NOTE: temporary solution until client.NewClient() is fixed smdClient.URI = params.URI if params.CaCertPath != "" { cacert, err := os.ReadFile(params.CaCertPath) if err != nil { - return fmt.Errorf("failed to read CA cert path: %w", err) + return nil, fmt.Errorf("failed to read CA cert path: %w", err) } certPool := x509.NewCertPool() certPool.AppendCertsFromPEM(cacert) - smdClient.Client.Transport = &http.Transport{ + smdClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: certPool, InsecureSkipVerify: true, @@ -103,12 +106,15 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { // generate custom xnames for bmcs // TODO: add xname customization via CLI - node := xnames.Node{ - Cabinet: 1000, - Chassis: 1, - ComputeModule: 7, - NodeBMC: offset, - } + var ( + uri = fmt.Sprintf("%s:%d", sr.Host, sr.Port) + node = xnames.Node{ + Cabinet: 1000, + Chassis: 1, + ComputeModule: 7, + NodeBMC: offset, + } + ) offset += 1 // crawl BMC node to fetch inventory data via Redfish @@ -116,19 +122,33 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { systems []crawler.InventoryDetail managers []crawler.Manager config = crawler.CrawlerConfig{ - URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port), - Username: params.Username, - Password: params.Password, - Insecure: true, + URI: uri, + CredentialStore: params.SecretStore, + Insecure: true, + UseDefault: true, } + err error ) - systems, err := crawler.CrawlBMCForSystems(config) + + // crawl for node and BMC information + systems, err = crawler.CrawlBMCForSystems(config) if err != nil { - log.Error().Err(err).Msg("failed to crawl BMC for systems") + log.Error().Err(err).Str("uri", uri).Msg("failed to crawl BMC for systems") } managers, err = crawler.CrawlBMCForManagers(config) if err != nil { - log.Error().Err(err).Msg("failed to crawl BMC for managers") + log.Error().Err(err).Str("uri", uri).Msg("failed to crawl BMC for managers") + } + + // we didn't find anything so do not proceed + if len(systems) == 0 && len(managers) == 0 { + continue + } + + // get BMC username to send + bmcCreds := bmc.GetBMCCredentialsOrDefault(params.SecretStore, config.URI) + if bmcCreds == (bmc.BMCCredentials{}) { + log.Warn().Str("id", config.URI).Msg("username will be blank") } // data to be sent to smd @@ -137,7 +157,7 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { "Type": "", "Name": "", "FQDN": sr.Host, - "User": params.Username, + "User": bmcCreds.Username, "MACRequired": true, "RediscoverOnUpdate": false, "Systems": systems, @@ -169,6 +189,9 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { fmt.Printf("%v\n", string(body)) } + // add data output to collections + collection = append(collection, data) + // write JSON data to file if output path is set using hive partitioning strategy if outputPath != "" { var ( @@ -241,7 +264,7 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) error { wg.Wait() close(done) - return nil + return collection, nil } // FindMACAddressWithIP() returns the MAC address of an ethernet interface with @@ -256,10 +279,15 @@ func FindMACAddressWithIP(config crawler.CrawlerConfig, targetIP net.IP) (string // gofish (at least for now). If there's a need for grabbing more // manager information in the future, we can move the logic into // the crawler. + bmc_creds, err := config.GetUserPass() + if err != nil { + return "", fmt.Errorf("failed to get credentials for URI: %s", config.URI) + } + client, err := gofish.Connect(gofish.ClientConfig{ Endpoint: config.URI, - Username: config.Username, - Password: config.Password, + Username: bmc_creds.Username, + Password: bmc_creds.Password, Insecure: config.Insecure, BasicAuth: true, }) diff --git a/pkg/crawler/identify.go b/pkg/crawler/identify.go new file mode 100644 index 0000000..be5b788 --- /dev/null +++ b/pkg/crawler/identify.go @@ -0,0 +1,69 @@ +package crawler + +import ( + "fmt" + + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/redfish" +) + +// BMCInfo represents relevant information about a BMC +type BMCInfo struct { + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + SerialNumber string `json:"serial_number"` + FirmwareVersion string `json:"firmware_version"` + ManagerType string `json:"manager_type"` + UUID string `json:"uuid"` +} + +// IsBMC checks if a given Manager is a BMC based on its type and associations +func IsBMC(manager *redfish.Manager) bool { + if manager == nil { + return false + } + + // Valid BMC types in Redfish + bmcTypes := map[string]bool{ + "BMC": true, + "ManagementController": true, // Some BMCs use this type + } + + // Check if ManagerType matches a BMC type + if !bmcTypes[string(manager.ManagerType)] { + return false + } + + return false // Otherwise, it's likely a chassis manager or other device +} + +// GetBMCInfo retrieves details of all available BMCs +func GetBMCInfo(client *gofish.APIClient) ([]BMCInfo, error) { + var bmcList []BMCInfo + + // Retrieve all managers (BMCs and other managers) + managers, err := client.Service.Managers() + if err != nil { + return nil, fmt.Errorf("failed to retrieve managers: %v", err) + } + + // Iterate through each manager and collect BMC details + for _, manager := range managers { + if !IsBMC(manager) { + continue // Skip if it's not a BMC + } + + bmc := BMCInfo{ + Manufacturer: manager.Manufacturer, + Model: manager.Model, + SerialNumber: manager.SerialNumber, + FirmwareVersion: manager.FirmwareVersion, + ManagerType: string(manager.ManagerType), // Convert ManagerType to string + UUID: manager.UUID, + } + + bmcList = append(bmcList, bmc) + } + + return bmcList, nil +} diff --git a/pkg/crawler/main.go b/pkg/crawler/main.go index 771efb9..3aa4900 100644 --- a/pkg/crawler/main.go +++ b/pkg/crawler/main.go @@ -4,16 +4,23 @@ import ( "fmt" "strings" + "github.com/davidallendj/magellan/internal/util" + "github.com/davidallendj/magellan/pkg/bmc" + "github.com/davidallendj/magellan/pkg/secrets" "github.com/rs/zerolog/log" "github.com/stmcginnis/gofish" "github.com/stmcginnis/gofish/redfish" ) type CrawlerConfig struct { - URI string // URI of the BMC - Username string // Username for the BMC - Password string // Password for the BMC - Insecure bool // Whether to ignore SSL errors + URI string // URI of the BMC + Insecure bool // Whether to ignore SSL errors + CredentialStore secrets.SecretStore + UseDefault bool +} + +func (cc *CrawlerConfig) GetUserPass() (bmc.BMCCredentials, error) { + return loadBMCCreds(*cc) } type EthernetInterface struct { @@ -82,11 +89,20 @@ func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) { systems []InventoryDetail rf_systems []*redfish.ComputerSystem ) + // get username and password from secret store + bmc_creds, err := loadBMCCreds(config) + if err != nil { + event := log.Error() + event.Err(err) + event.Msg("failed to load BMC credentials") + return nil, err + } + // initialize gofish client client, err := gofish.Connect(gofish.ClientConfig{ Endpoint: config.URI, - Username: config.Username, - Password: config.Password, + Username: bmc_creds.Username, + Password: bmc_creds.Password, Insecure: config.Insecure, BasicAuth: true, }) @@ -129,14 +145,40 @@ func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) { return walkSystems(rf_systems, nil, config.URI) } -// CrawlBMCForSystems pulls BMC manager information. +// CrawlBMCForManagers connects to a BMC (Baseboard Management Controller) using the provided configuration, +// retrieves the ServiceRoot, and then fetches the list of managers from the ServiceRoot. +// +// Parameters: +// - config: A CrawlerConfig struct containing the URI, username, password, and other connection details. +// +// Returns: +// - []Manager: A slice of Manager structs representing the managers retrieved from the BMC. +// - error: An error object if any error occurs during the connection or retrieval process. +// +// The function performs the following steps: +// 1. Initializes a gofish client with the provided configuration. +// 2. Attempts to connect to the BMC using the gofish client. +// 3. Handles specific connection errors such as 404 (ServiceRoot not found) and 401 (authentication failed). +// 4. Logs out from the client after the operations are completed. +// 5. Retrieves the ServiceRoot from the connected BMC. +// 6. Fetches the list of managers from the ServiceRoot. +// 7. Returns the list of managers and any error encountered during the process. func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) { + + // get username and password from secret store + bmc_creds, err := loadBMCCreds(config) + if err != nil { + event := log.Error() + event.Err(err) + event.Msg("failed to load BMC credentials") + return nil, err + } // initialize gofish client var managers []Manager client, err := gofish.Connect(gofish.ClientConfig{ Endpoint: config.URI, - Username: config.Username, - Password: config.Password, + Username: bmc_creds.Username, + Password: bmc_creds.Password, Insecure: config.Insecure, BasicAuth: true, }) @@ -165,6 +207,27 @@ func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) { return walkManagers(rf_managers, config.URI) } +// walkSystems processes a list of Redfish computer systems and their associated chassis, +// and returns a list of inventory details for each system. +// +// Parameters: +// - rf_systems: A slice of pointers to redfish.ComputerSystem objects representing the computer systems to be processed. +// - rf_chassis: A pointer to a redfish.Chassis object representing the chassis associated with the computer systems. +// - baseURI: A string representing the base URI for constructing resource URIs. +// +// Returns: +// - A slice of InventoryDetail objects containing detailed information about each computer system. +// - An error if any issues occur while processing the computer systems or their associated resources. +// +// The function performs the following steps: +// 1. Iterates over each computer system in rf_systems. +// 2. Constructs an InventoryDetail object for each computer system, populating fields such as URI, UUID, Name, Manufacturer, SystemType, Model, Serial, BiosVersion, PowerState, ProcessorCount, ProcessorType, and MemoryTotal. +// 3. If rf_chassis is not nil, populates additional chassis-related fields in the InventoryDetail object. +// 4. Retrieves and processes Ethernet interfaces for each computer system, adding them to the EthernetInterfaces field of the InventoryDetail object. +// 5. Retrieves and processes Network interfaces and their associated network adapters for each computer system, adding them to the NetworkInterfaces field of the InventoryDetail object. +// 6. Processes trusted modules for each computer system, adding them to the TrustedModules field of the InventoryDetail object. +// 7. Appends the populated InventoryDetail object to the systems slice. +// 8. Returns the systems slice and any error encountered during processing. func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chassis, baseURI string) ([]InventoryDetail, error) { systems := []InventoryDetail{} for _, rf_computersystem := range rf_systems { @@ -253,6 +316,23 @@ func walkSystems(rf_systems []*redfish.ComputerSystem, rf_chassis *redfish.Chass return systems, nil } +// walkManagers processes a list of Redfish managers and extracts relevant information +// to create a slice of Manager objects. +// +// Parameters: +// +// rf_managers - A slice of pointers to redfish.Manager objects representing the Redfish managers to be processed. +// baseURI - A string representing the base URI to be used for constructing URIs for the managers and their Ethernet interfaces. +// +// Returns: +// +// A slice of Manager objects containing the extracted information from the provided Redfish managers. +// An error if any issues occur while retrieving Ethernet interfaces from the managers. +// +// The function iterates over each Redfish manager, retrieves its Ethernet interfaces, +// and constructs a Manager object with the relevant details, including Ethernet interface information. +// If an error occurs while retrieving Ethernet interfaces, the function logs the error and returns the managers +// collected so far along with the error. func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, error) { var managers []Manager for _, rf_manager := range rf_managers { @@ -288,3 +368,15 @@ func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, er } return managers, nil } + +func loadBMCCreds(config CrawlerConfig) (bmc.BMCCredentials, error) { + // NOTE: it is possible for the SecretStore to be nil, so we need a check + if config.CredentialStore == nil { + return bmc.BMCCredentials{}, fmt.Errorf("credential store is invalid") + } + if creds := util.GetBMCCredentials(config.CredentialStore, config.URI); creds == (bmc.BMCCredentials{}) { + return creds, fmt.Errorf("%s: credentials blank for BNC", config.URI) + } else { + return creds, nil + } +} diff --git a/internal/scan.go b/pkg/scan.go similarity index 99% rename from internal/scan.go rename to pkg/scan.go index 99b4904..c1b5f13 100644 --- a/internal/scan.go +++ b/pkg/scan.go @@ -203,7 +203,7 @@ func rawConnect(address string, protocol string, timeoutSeconds int, keepOpenOnl ) // try to conntect to host (expects host in format [10.0.0.0]:443) - target := fmt.Sprintf("%s:%s", uri.Hostname(), uri.Port()) + target := net.JoinHostPort(uri.Hostname(), uri.Port()) conn, err := net.DialTimeout(protocol, target, timeoutDuration) if err != nil { asset.State = false diff --git a/pkg/secrets/encryption.go b/pkg/secrets/encryption.go new file mode 100644 index 0000000..6faa737 --- /dev/null +++ b/pkg/secrets/encryption.go @@ -0,0 +1,75 @@ +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" +) + +// Derive a unique AES key per SecretID using HKDF +func deriveAESKey(masterKey []byte, secretID string) []byte { + salt := []byte(secretID) + hkdf := hkdf.New(sha256.New, masterKey, salt, nil) + derivedKey := make([]byte, 32) // AES-256 key + io.ReadFull(hkdf, derivedKey) + return derivedKey +} + +// Encrypt data using AES-GCM +func encryptAESGCM(key, plaintext []byte) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, aesGCM.NonceSize()) + _, err = io.ReadFull(rand.Reader, nonce) + if err != nil { + return "", err + } + + ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil) + return hex.EncodeToString(ciphertext), nil +} + +// Decrypt data using AES-GCM +func decryptAESGCM(key []byte, encryptedData string) (string, error) { + data, err := hex.DecodeString(encryptedData) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := aesGCM.NonceSize() + if len(data) < nonceSize { + return "", fmt.Errorf("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} diff --git a/pkg/secrets/encryption_test.go b/pkg/secrets/encryption_test.go new file mode 100644 index 0000000..bc1919b --- /dev/null +++ b/pkg/secrets/encryption_test.go @@ -0,0 +1,41 @@ +package secrets + +import ( + "testing" +) + +func TestDeriveAESKey(t *testing.T) { + masterKey := []byte("testmasterkey") + secretID := "mySecretID" + key1 := deriveAESKey(masterKey, secretID) + key2 := deriveAESKey(masterKey, secretID) + + if len(key1) != 32 { + t.Errorf("derived key should be 32 bytes, got %d", len(key1)) + } + if string(key1) != string(key2) { + t.Errorf("keys derived from same secretID should match") + } +} + +func TestEncryptDecryptAESGCM(t *testing.T) { + masterKey := []byte("anotherTestMasterKey") + secretID := "testSecret" + plaintext := "Hello, secrets!" + + key := deriveAESKey(masterKey, secretID) + + encrypted, err := encryptAESGCM(key, []byte(plaintext)) + if err != nil { + t.Fatalf("encryption failed: %v", err) + } + + decrypted, err := decryptAESGCM(key, encrypted) + if err != nil { + t.Fatalf("decryption failed: %v", err) + } + + if decrypted != plaintext { + t.Errorf("expected %q, got %q", plaintext, decrypted) + } +} diff --git a/pkg/secrets/example/main.go b/pkg/secrets/example/main.go new file mode 100644 index 0000000..d5739b8 --- /dev/null +++ b/pkg/secrets/example/main.go @@ -0,0 +1,212 @@ +package main + +// This example demonstrates the usage of the LocalSecretStore to store and retrieve secrets. +// It provides a command-line interface to generate a master key, store secrets, and retrieve them. +// The master key is assumed to be stored in the environment variable MASTER_KEY and while it can +// anything you want, we recommend a 32 bit key for AES-256 encryption. The master key is used +// as part of a Key Derivation Function (KDF) to generate a unique AES key for each secret. +// The algorithm of choice is HMAC-based Extract-and-Expand Key Derivation Function (HKDF). +// Each secret is separately encrypted using AES-GCM and stored in a JSON file. +// The JSON file is loaded into memory when the LocalSecretStore is created and saved back to the file +// when a secret is stored or removed. +// + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/davidallendj/magellan/pkg/secrets" +) + +func usage() { + fmt.Println("Usage:") + fmt.Println(" go run main.go generatekey") + fmt.Println(" - Generates a new 32-byte master key (in hex).") + fmt.Println() + fmt.Println(" Export MASTER_KEY= to use the same key in the next commands.") + fmt.Println() + fmt.Println(" go run main.go store [filename]") + fmt.Println(" - Stores the given string value under secretID.") + fmt.Println() + fmt.Println(" go run main.go storebase64 [filename]") + fmt.Println(" - Decodes the base64-encoded string before storing.") + fmt.Println() + fmt.Println(" go run main.go storejson [filename]") + fmt.Println(" - Stores the provided JSON for the specified secretID.") + fmt.Println() + fmt.Println(" go run main.go retrieve [filename]") + fmt.Println(" - Retrieves and prints the secret value for the given secretID.") + fmt.Println() + fmt.Println(" go run main.go list [filename]") + fmt.Println(" - Lists all the secret IDs and their values.") + fmt.Println() +} + +// openStore tries to create or open the LocalSecretStore based on the environment +// variable MASTER_KEY. If not found, it prints an error. +func openStore(filename string) (*secrets.LocalSecretStore, error) { + masterKey := os.Getenv("MASTER_KEY") + if masterKey == "" { + return nil, fmt.Errorf("MASTER_KEY environment variable not set") + } + + store, err := secrets.NewLocalSecretStore(masterKey, filename, true) + if err != nil { + return nil, fmt.Errorf("cannot open secrets store: %v", err) + } + return store, nil +} + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + + cmd := os.Args[1] + + switch cmd { + case "generatekey": + key, err := secrets.GenerateMasterKey() + if err != nil { + fmt.Printf("Error generating master key: %v\n", err) + os.Exit(1) + } + fmt.Printf("%s\n", key) + + case "store": + if len(os.Args) < 4 { + fmt.Println("Not enough arguments. Usage: go run main.go store [filename]") + os.Exit(1) + } + secretID := os.Args[2] + secretValue := os.Args[3] + filename := "mysecrets.json" + if len(os.Args) == 5 { + filename = os.Args[4] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if err := store.StoreSecretByID(secretID, secretValue); err != nil { + fmt.Printf("Error storing secret: %v\n", err) + os.Exit(1) + } + fmt.Println("Secret stored successfully.") + + case "storebase64": + if len(os.Args) < 4 { + fmt.Println("Not enough arguments. Usage: go run main.go storebase64 [filename]") + os.Exit(1) + } + secretID := os.Args[2] + base64Value := os.Args[3] + filename := "mysecrets.json" + if len(os.Args) == 5 { + filename = os.Args[4] + } + + decoded, err := base64.StdEncoding.DecodeString(base64Value) + if err != nil { + fmt.Printf("Error decoding base64 data: %v\n", err) + os.Exit(1) + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if err := store.StoreSecretByID(secretID, string(decoded)); err != nil { + fmt.Printf("Error storing base64-decoded secret: %v\n", err) + os.Exit(1) + } + fmt.Println("Base64-decoded secret stored successfully.") + + case "storejson": + if len(os.Args) < 4 { + fmt.Println(`Not enough arguments. Usage: go run main.go storejson '{"key":"value"}' [filename]`) + os.Exit(1) + } + secretID := os.Args[2] + jsonValue := os.Args[3] + filename := "mysecrets.json" + if len(os.Args) == 5 { + filename = os.Args[4] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if err := store.StoreSecretByID(secretID, jsonValue); err != nil { + fmt.Printf("Error storing JSON secret: %v\n", err) + os.Exit(1) + } + fmt.Println("JSON secret stored successfully.") + + case "retrieve": + if len(os.Args) < 3 { + fmt.Println("Not enough arguments. Usage: go run main.go retrieve [filename]") + os.Exit(1) + } + secretID := os.Args[2] + filename := "mysecrets.json" + if len(os.Args) == 4 { + filename = os.Args[3] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + secretValue, err := store.GetSecretByID(secretID) + if err != nil { + fmt.Printf("Error retrieving secret: %v\n", err) + os.Exit(1) + } + fmt.Printf("Secret for %s: %s\n", secretID, secretValue) + + case "list": + if len(os.Args) < 2 { + fmt.Println("Not enough arguments. Usage: go run main.go list [filename]") + os.Exit(1) + } + + filename := "mysecrets.json" + if len(os.Args) == 3 { + filename = os.Args[2] + } + + store, err := openStore(filename) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + secrets, err := store.ListSecrets() + if err != nil { + fmt.Printf("Error listing secrets: %v\n", err) + os.Exit(1) + } + + fmt.Println("Secrets:") + for key, value := range secrets { + fmt.Printf("%s: %s\n", key, value) + } + + default: + usage() + } + +} diff --git a/pkg/secrets/localstore.go b/pkg/secrets/localstore.go new file mode 100644 index 0000000..f426320 --- /dev/null +++ b/pkg/secrets/localstore.go @@ -0,0 +1,161 @@ +package secrets + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "sync" +) + +// Structure to store encrypted secrets in a JSON file +type LocalSecretStore struct { + mu sync.RWMutex + masterKey []byte + filename string + Secrets map[string]string `json:"secrets"` +} + +func NewLocalSecretStore(masterKeyHex, filename string, create bool) (*LocalSecretStore, error) { + var secrets map[string]string + + masterKey, err := hex.DecodeString(masterKeyHex) + if err != nil { + return nil, fmt.Errorf("unable to generate masterkey from hex representation: %v", err) + } + + if _, err := os.Stat(filename); os.IsNotExist(err) { + if !create { + return nil, fmt.Errorf("file %s does not exist", filename) + } + file, err := os.Create(filename) + if err != nil { + return nil, fmt.Errorf("unable to create file %s: %v", filename, err) + } + file.Close() + secrets = make(map[string]string) + } + + if secrets == nil { + secrets, err = loadSecrets(filename) + if err != nil { + return nil, fmt.Errorf("unable to load secrets from file: %v", err) + } + } + + return &LocalSecretStore{ + masterKey: masterKey, + filename: filename, + Secrets: secrets, + }, nil +} + +// GenerateMasterKey creates a 32-byte random key and returns it as a hex string. +func GenerateMasterKey() (string, error) { + key := make([]byte, 32) // 32 bytes for AES-256 + _, err := rand.Read(key) + if err != nil { + return "", err + } + return hex.EncodeToString(key), nil +} + +// GetSecretByID decrypts the secret using the master key and returns it +func (l *LocalSecretStore) GetSecretByID(secretID string) (string, error) { + l.mu.RLock() + encrypted, exists := l.Secrets[secretID] + l.mu.RUnlock() + if !exists { + return "", fmt.Errorf("no secret found for %s", secretID) + } + + derivedKey := deriveAESKey(l.masterKey, secretID) + return decryptAESGCM(derivedKey, encrypted) +} + +// StoreSecretByID encrypts the secret using the master key and stores it in the JSON file +func (l *LocalSecretStore) StoreSecretByID(secretID, secret string) error { + derivedKey := deriveAESKey(l.masterKey, secretID) + encryptedSecret, err := encryptAESGCM(derivedKey, []byte(secret)) + if err != nil { + return err + } + + l.mu.Lock() + l.Secrets[secretID] = encryptedSecret + err = SaveSecrets(l.filename, l.Secrets) + l.mu.Unlock() + return err +} + +// ListSecrets returns a copy of secret IDs to secrets stored in memory +func (l *LocalSecretStore) ListSecrets() (map[string]string, error) { + l.mu.RLock() + defer l.mu.RUnlock() + + secretsCopy := make(map[string]string) + for key, value := range l.Secrets { + secretsCopy[key] = value + } + return secretsCopy, nil +} + +// RemoveSecretByID removes the specified secretID stored locally +func (l *LocalSecretStore) RemoveSecretByID(secretID string) error { + l.mu.RLock() + // Let user know if there was nothing to delete + _, err := l.GetSecretByID(secretID) + if err != nil { + return err + } + delete(l.Secrets, secretID) + l.mu.RUnlock() + return nil +} + +// openStore tries to create or open the LocalSecretStore based on the environment +// variable MASTER_KEY. If not found, it prints an error. +func OpenStore(filename string) (SecretStore, error) { + if filename == "" { + return nil, fmt.Errorf("path to secret store required") + } + + masterKey := os.Getenv("MASTER_KEY") + if masterKey == "" { + return nil, fmt.Errorf("MASTER_KEY environment variable not set") + } + + store, err := NewLocalSecretStore(masterKey, filename, true) + if err != nil { + return nil, fmt.Errorf("failed to create new local secret store: %v", err) + } + return store, nil +} + +// Saves secrets back to the JSON file +func SaveSecrets(jsonFile string, store map[string]string) error { + file, err := os.OpenFile(jsonFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return err + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(store) +} + +// Loads the secrets JSON file +func loadSecrets(jsonFile string) (map[string]string, error) { + file, err := os.Open(jsonFile) + if err != nil { + return nil, fmt.Errorf("unable to open secret file %s:%v", jsonFile, err) + } + defer file.Close() + + store := make(map[string]string) + decoder := json.NewDecoder(file) + err = decoder.Decode(&store) + return store, err +} diff --git a/pkg/secrets/localstore_test.go b/pkg/secrets/localstore_test.go new file mode 100644 index 0000000..4009946 --- /dev/null +++ b/pkg/secrets/localstore_test.go @@ -0,0 +1,151 @@ +package secrets + +import ( + "encoding/hex" + "os" + "testing" +) + +func TestNewLocalSecretStore(t *testing.T) { + masterKey, err := GenerateMasterKey() + if err != nil { + t.Fatalf("Failed to generate master key: %v", err) + } + + filename := "test_secrets.json" + defer os.Remove(filename) + + store, err := NewLocalSecretStore(masterKey, filename, true) + if err != nil { + t.Fatalf("Failed to create LocalSecretStore: %v", err) + } + + if store.filename != filename { + t.Errorf("Expected filename %s, got %s", filename, store.filename) + } + + if hex.EncodeToString(store.masterKey) != masterKey { + t.Errorf("Expected master key %s, got %s", masterKey, hex.EncodeToString(store.masterKey)) + } +} + +func TestGenerateMasterKey(t *testing.T) { + key, err := GenerateMasterKey() + if err != nil { + t.Fatalf("Failed to generate master key: %v", err) + } + + if len(key) != 64 { // 32 bytes in hex representation + t.Errorf("Expected key length 64, got %d", len(key)) + } +} + +func TestStoreAndGetSecretByID(t *testing.T) { + masterKey, err := GenerateMasterKey() + if err != nil { + t.Fatalf("Failed to generate master key: %v", err) + } + + filename := "test_secrets.json" + defer os.Remove(filename) + + store, err := NewLocalSecretStore(masterKey, filename, true) + if err != nil { + t.Fatalf("Failed to create LocalSecretStore: %v", err) + } + + secretID := "test_secret" + secretValue := "my_secret_value" + + err = store.StoreSecretByID(secretID, secretValue) + if err != nil { + t.Fatalf("Failed to store secret: %v", err) + } + + retrievedSecret, err := store.GetSecretByID(secretID) + if err != nil { + t.Fatalf("Failed to get secret: %v", err) + } + + if retrievedSecret != secretValue { + t.Errorf("Expected secret value %s, got %s", secretValue, retrievedSecret) + } +} + +func TestStoreAndGetSecretJSON(t *testing.T) { + masterKey, err := GenerateMasterKey() + if err != nil { + t.Fatalf("Failed to generate master key: %v", err) + } + + filename := "test_secrets.json" + defer os.Remove(filename) + + store, err := NewLocalSecretStore(masterKey, filename, true) + if err != nil { + t.Fatalf("Failed to create LocalSecretStore: %v", err) + } + + secretID := "json_creds" + jsonSecret := `{"username":"testUser","password":"testPass"}` + + if err := store.StoreSecretByID(secretID, jsonSecret); err != nil { + t.Fatalf("Failed to store JSON secret: %v", err) + } + + retrieved, err := store.GetSecretByID(secretID) + if err != nil { + t.Fatalf("Failed to get JSON secret by ID: %v", err) + } + + if retrieved != jsonSecret { + t.Errorf("Expected %s, got %s", jsonSecret, retrieved) + } +} + +func TestListSecrets(t *testing.T) { + masterKey, err := GenerateMasterKey() + if err != nil { + t.Fatalf("Failed to generate master key: %v", err) + } + + filename := "test_secrets.json" + defer os.Remove(filename) + + store, err := NewLocalSecretStore(masterKey, filename, true) + if err != nil { + t.Fatalf("Failed to create LocalSecretStore: %v", err) + } + + secretID1 := "test_secret_1" + secretValue1 := "my_secret_value_1" + secretID2 := "test_secret_2" + secretValue2 := "my_secret_value_2" + + err = store.StoreSecretByID(secretID1, secretValue1) + if err != nil { + t.Fatalf("Failed to store secret: %v", err) + } + + err = store.StoreSecretByID(secretID2, secretValue2) + if err != nil { + t.Fatalf("Failed to store secret: %v", err) + } + + secrets, err := store.ListSecrets() + if err != nil { + t.Fatalf("Failed to list secrets: %v", err) + } + + if len(secrets) != 2 { + t.Errorf("Expected 2 secrets, got %d", len(secrets)) + } + + if secrets[secretID1] != store.Secrets[secretID1] { + t.Errorf("Expected secret value %s, got %s", store.Secrets[secretID1], secrets[secretID1]) + } + + if secrets[secretID2] != store.Secrets[secretID2] { + t.Errorf("Expected secret value %s, got %s", store.Secrets[secretID2], secrets[secretID2]) + } +} diff --git a/pkg/secrets/main.go b/pkg/secrets/main.go new file mode 100644 index 0000000..983f159 --- /dev/null +++ b/pkg/secrets/main.go @@ -0,0 +1,10 @@ +package secrets + +const DEFAULT_KEY = "default" + +type SecretStore interface { + GetSecretByID(secretID string) (string, error) + StoreSecretByID(secretID, secret string) error + ListSecrets() (map[string]string, error) + RemoveSecretByID(secretID string) error +} diff --git a/pkg/secrets/staticstore.go b/pkg/secrets/staticstore.go new file mode 100644 index 0000000..40d9049 --- /dev/null +++ b/pkg/secrets/staticstore.go @@ -0,0 +1,35 @@ +package secrets + +import "fmt" + +type StaticStore struct { + Username string + Password string +} + +// NewStaticStore creates a new StaticStore with the given username and password. +func NewStaticStore(username, password string) *StaticStore { + return &StaticStore{ + Username: username, + Password: password, + } +} + +func (s *StaticStore) GetSecretByID(secretID string) (string, error) { + return fmt.Sprintf(`{"username":"%s","password":"%s"}`, s.Username, s.Password), nil +} + +func (s *StaticStore) StoreSecretByID(secretID, secret string) error { + return nil +} + +func (s *StaticStore) ListSecrets() (map[string]string, error) { + return map[string]string{ + "static_creds": fmt.Sprintf(`{"username":"%s","password":"%s"}`, s.Username, s.Password), + }, nil +} + +func (s *StaticStore) RemoveSecretByID(secretID string) error { + // Nothing to do here, since nothing is being stored. With different implementations, we could return an error when no secret is found for a specific ID. + return nil +} diff --git a/pkg/update.go b/pkg/update.go new file mode 100644 index 0000000..838ba0a --- /dev/null +++ b/pkg/update.go @@ -0,0 +1,103 @@ +package magellan + +import ( + "fmt" + "net/url" + + "github.com/davidallendj/magellan/pkg/bmc" + "github.com/stmcginnis/gofish" + "github.com/stmcginnis/gofish/redfish" +) + +type UpdateParams struct { + CollectParams + FirmwareURI string + TransferProtocol string + Insecure bool +} + +// UpdateFirmwareRemote() uses 'gofish' to update the firmware of a BMC node. +// The function expects the firmware URL, firmware version, and component flags to be +// set from the CLI to perform a firmware update. +// Example: +// ./magellan update https://192.168.23.40 --username root --password 0penBmc +// --firmware-url http://192.168.23.19:1337/obmc-phosphor-image.static.mtd.tar +// --scheme TFTP +// +// being: +// q.URI https://192.168.23.40 +// q.TransferProtocol TFTP +// q.FirmwarePath http://192.168.23.19:1337/obmc-phosphor-image.static.mtd.tar +func UpdateFirmwareRemote(q *UpdateParams) error { + // parse URI to set up full address + uri, err := url.ParseRequestURI(q.URI) + if err != nil { + return fmt.Errorf("failed to parse URI: %w", err) + } + + // Get BMC credentials from secret store in update parameters + bmcCreds, err := bmc.GetBMCCredentials(q.SecretStore, q.URI) + if err != nil { + return fmt.Errorf("failed to get BMC credentials: %w", err) + } + + // Connect to the Redfish service using gofish + client, err := gofish.Connect(gofish.ClientConfig{Endpoint: uri.String(), Username: bmcCreds.Username, Password: bmcCreds.Password, Insecure: q.Insecure}) + if err != nil { + return fmt.Errorf("failed to connect to Redfish service: %w", err) + } + defer client.Logout() + + // Retrieve the UpdateService from the Redfish client + updateService, err := client.Service.UpdateService() + if err != nil { + return fmt.Errorf("failed to get update service: %w", err) + } + + // Build the update request payload + req := redfish.SimpleUpdateParameters{ + ImageURI: q.FirmwareURI, + TransferProtocol: redfish.TransferProtocolType(q.TransferProtocol), + } + + // Execute the SimpleUpdate action + err = updateService.SimpleUpdate(&req) + if err != nil { + return fmt.Errorf("firmware update failed: %w", err) + } + fmt.Println("Firmware update initiated successfully.") + return nil +} + +func GetUpdateStatus(q *UpdateParams) error { + // parse URI to set up full address + uri, err := url.ParseRequestURI(q.URI) + if err != nil { + return fmt.Errorf("failed to parse URI: %w", err) + } + + // Get BMC credentials from secret store in update parameters + bmcCreds, err := bmc.GetBMCCredentials(q.SecretStore, q.URI) + if err != nil { + return fmt.Errorf("failed to get BMC credentials: %w", err) + } + + // Connect to the Redfish service using gofish + client, err := gofish.Connect(gofish.ClientConfig{Endpoint: uri.String(), Username: bmcCreds.Username, Password: bmcCreds.Password, Insecure: q.Insecure}) + if err != nil { + return fmt.Errorf("failed to connect to Redfish service: %w", err) + } + defer client.Logout() + + // Retrieve the UpdateService from the Redfish client + updateService, err := client.Service.UpdateService() + if err != nil { + return fmt.Errorf("failed to get update service: %w", err) + } + + // Get the update status + status := updateService.Status + fmt.Printf("Update Status: %v\n", status) + + return nil +} diff --git a/tests/api_test.go b/tests/api_test.go index d46a312..572b941 100644 --- a/tests/api_test.go +++ b/tests/api_test.go @@ -22,8 +22,8 @@ import ( "flag" - magellan "github.com/davidallendj/magellan/internal" "github.com/davidallendj/magellan/internal/util" + magellan "github.com/davidallendj/magellan/pkg" "github.com/davidallendj/magellan/pkg/client" "github.com/rs/zerolog/log" ) diff --git a/tests/compatibility_test.go b/tests/compatibility_test.go index c947e17..766b96b 100644 --- a/tests/compatibility_test.go +++ b/tests/compatibility_test.go @@ -16,6 +16,7 @@ import ( "github.com/davidallendj/magellan/pkg/client" "github.com/davidallendj/magellan/pkg/crawler" + "github.com/davidallendj/magellan/pkg/secrets" ) var ( @@ -77,7 +78,7 @@ func TestRedfishV1ServiceRootAvailability(t *testing.T) { // Simple test to ensure an expected Redfish version minimum requirement. func TestRedfishV1Version(t *testing.T) { var ( - url string = fmt.Sprintf("%s/redfish/v1/", *host) + url = fmt.Sprintf("%s/redfish/v1/", *host) body client.HTTPBody = []byte{} headers client.HTTPHeader = map[string]string{} testClient = &http.Client{ @@ -126,12 +127,17 @@ func TestExpectedOutput(t *testing.T) { t.Fatalf("failed while waiting for emulator: %v", err) } + // initialize a credential store + staticStore := &secrets.StaticStore{ + Username: *username, + Password: *password, + } + systems, err := crawler.CrawlBMCForSystems( crawler.CrawlerConfig{ - URI: *host, - Username: *username, - Password: *password, - Insecure: true, + URI: *host, + CredentialStore: staticStore, + Insecure: true, }, )