Compare commits

...

83 commits

Author SHA1 Message Date
d34ba3f754
chore: update references and imports 2025-04-26 18:12:21 -06:00
665cd4bc14
refactor: exported more cmd variables 2025-04-26 17:54:41 -06:00
a9a3ebee20
refactor: updated references 2025-04-26 17:54:41 -06:00
f3ede4117f
updated all references 2025-04-26 17:54:41 -06:00
0f811543f5
Changed DeleteScannedAssets to work correct and added db tag 2025-04-26 17:50:29 -06:00
738685095f
readme: updated information about default secrets 2025-04-26 17:46:52 -06:00
ad30eb297d
fix: added check to stop collect on error 2025-04-26 17:46:52 -06:00
5d9b382921
fix: changed persistent flags in collect to fix binding 2025-04-26 17:46:52 -06:00
Devon Bautista
979841d762
fix: clarify that override is temporary 2025-04-26 17:46:52 -06:00
Devon Bautista
ed9db6d943
fix: only do it once 2025-04-26 17:46:51 -06:00
Devon Bautista
26e39d777b
fix: log override for local store too 2025-04-26 17:46:51 -06:00
Devon Bautista
5d9afebcb1
fix: move BMC credentials getter that logs to util func 2025-04-26 17:46:51 -06:00
Devon Bautista
939be12da7
fix(collect): properly set secret when overriding with flags 2025-04-26 17:46:51 -06:00
Devon Bautista
e4a521971a
fix(bmc): check for default key 2025-04-26 17:46:51 -06:00
Devon Bautista
7bcd2f9462
fix(collect): don;t require both creds flags 2025-04-26 17:46:51 -06:00
Devon Bautista
0ed861e3a7
fix(collect): make sure secret store is set 2025-04-26 17:46:50 -06:00
Devon Bautista
541fb6ebb0
fix: adjust secret store precedence in collect command 2025-04-26 17:46:50 -06:00
Devon Bautista
0909254550
feat: add secret store support to update command 2025-04-26 17:46:50 -06:00
Devon Bautista
93010587c6
refactor: split BMC data structures into pkg/bmc package 2025-04-26 17:46:50 -06:00
Devon Bautista
e62a38183f
feat(crawl): improved logs; add partial credential overriding
The order of precedence is:

1. --username/--password
2. URI-specific credentials in store
3. Default credentials in store

The "partial" overriding means that specifying only one of --username or
--password will cause the crawl command to fetch the specific node
credentials for the BMC (falling back to the default) but override the
result with the value of the passed flag.

For instance,

magellan crawl <uri> --username foo

will fetch the username and password for <uri> in the secret store, but
override the username being sent to 'foo'. This does not change the
username stored in the secret store.
2025-04-26 17:46:50 -06:00
Devon Bautista
a6dadfcdb5
chore: run gofmt 2025-04-26 17:46:50 -06:00
ee99d6e06d
chore: updated error/warn messages to be more informative 2025-04-26 17:46:50 -06:00
3074e7323a
readme: added tip about default secrets 2025-04-26 17:46:49 -06:00
38e22ff24c
feat: add default secret to local store 2025-04-26 17:46:49 -06:00
92b05a81c7
refactor: improvements to CLI and update pkg 2025-04-26 17:46:49 -06:00
b7761c2cbf
readme: update with secrets and emulator sections 2025-04-26 17:46:49 -06:00
5b28ea4575
cmd: allow short opts for username/password 2025-04-26 17:46:01 -06:00
e20b6a3b8e
makefile: corrected golangci-lint install string 2025-04-26 17:46:01 -06:00
5e200edab5
lint: apply changes from golint 2025-04-26 17:46:00 -06:00
69abd2041d
makefile: updated golangci-lint version 2025-04-26 17:46:00 -06:00
94a339e39e
refactor: changed var name for clarity and added logging details 2025-04-26 17:46:00 -06:00
6c5e958863
fix: added username/password to collect params 2025-04-26 17:46:00 -06:00
b7cf7233a8
fix: collect not falling back to CLI args correctly 2025-04-26 17:46:00 -06:00
2b0245e17b
refactor: added check for secretID in secrets store 2025-04-26 17:46:00 -06:00
cc112e72e4
refactor: changed logging to use consistent JSON format 2025-04-26 17:46:00 -06:00
5c4ca34976
refactor: use vars for cred flags 2025-04-26 17:45:59 -06:00
d1042d77aa
refactor: changed short opts for secret store 2025-04-26 17:45:59 -06:00
1ba78539fb
refactor: added basic input format and cleanup 2025-04-26 17:45:59 -06:00
da8b1a1756
refactor: minor changes to error messages 2025-04-26 17:45:59 -06:00
4dd01867f7
refactor: change error message to warning 2025-04-26 17:45:59 -06:00
5c624de821
refactor: export function to save JSON secrets 2025-04-26 17:45:59 -06:00
6ae0121af7
fix: secrets remove not updating local store and return error when not found 2025-04-26 17:45:59 -06:00
0333caa403
refactor: changed removing secret from store returns error 2025-04-26 17:45:58 -06:00
c3e1b40e3b
fix: changed number of minimum args for secrets list 2025-04-26 17:45:58 -06:00
8866dff307
refactor: added flag to set secrets file for crawl 2025-04-26 17:45:58 -06:00
149fcaec6d
refactor: changed to use local store with static store fallback 2025-04-26 17:45:58 -06:00
07e3d0eb7a
refactor: changed required number of args for secrets list 2025-04-26 17:45:58 -06:00
0569329529
refactor: added exact number of args to list cmd 2025-04-26 17:45:58 -06:00
d4443ac6c9
fix: added secrets file path to collect parameters 2025-04-26 17:45:57 -06:00
a4928b9ebb
refactor: minor changes to store in collect 2025-04-26 17:45:57 -06:00
b49b1f761d
fix: added missing funcs for secret store implementations 2025-04-26 17:45:57 -06:00
35cf2222a0
refactor: added func to remove secrets from store 2025-04-26 17:45:57 -06:00
9e831914df
refactor: updated secrets cmd implementation 2025-04-26 17:45:57 -06:00
51ff7b098c
feat: add 'secrets' command to root 2025-04-26 17:45:57 -06:00
22af66f95c
chore: updated go deps 2025-04-26 17:45:57 -06:00
842e864384
refactor: updated description/example and added 'secrets-file' flag to cmd 2025-04-26 17:44:49 -06:00
f9059c50a1
refactor: added optional secrets file parameter and lookup in collect 2025-04-26 17:44:48 -06:00
9396de11c2
chore: added pre-condition guards for secrets 2025-04-26 17:44:48 -06:00
cdf380bd64
refactor: added function to open secrets store by checking env var 2025-04-26 17:44:48 -06:00
David Allen
5aacfceb0d
Fix emulator image in rf-emulator.yml
Signed-off-by: David Allen <16520934+davidallendj@users.noreply.github.com>
2025-04-26 17:44:48 -06:00
David Allen
d45bfa333d
fix: update goimports in update.go 2025-04-26 17:43:17 -06:00
397571c441
collect: return collection output from CollectInventory() 2025-04-26 17:42:00 -06:00
David Allen
983985464b
chore: fix critical dependabot issues by updating crypto 2025-04-26 17:39:09 -06:00
Alex Lovell-Troy
9b1147d177
feat(secrets): implement SecretStore interface and StaticStore/LocalStore for credential management 2025-04-26 17:37:04 -06:00
f47869069b
collect: return collection output from CollectInventory() 2025-04-26 17:37:04 -06:00
0b16bf2ef6
refactor: moved internal functions to pkg and updated refs 2025-04-26 17:35:04 -06:00
David Allen
33c333a071
chore: fix critical dependabot issues by updating crypto 2025-04-26 17:21:47 -06:00
Alex Lovell-Troy
dd944245c7
feat: enhance firmware update functionality and add BMC identification support 2025-04-26 17:19:13 -06:00
Alex Lovell-Troy
8b3f02b5b5
feat(secrets): implement SecretStore interface and StaticStore/LocalStore for credential management 2025-04-26 17:19:12 -06:00
40a82d1c66
collect: return collection output from CollectInventory() 2025-04-26 17:19:12 -06:00
b0ff7a8d38
refactor: moved internal functions to pkg and updated refs 2025-04-26 17:19:12 -06:00
David Allen
14453bbbaf
chore: fix critical dependabot issues by updating crypto 2025-04-26 17:19:12 -06:00
abe0b5e27a
bugfix: fixed URL param not being set for UpdateFirmwareRemote 2025-04-26 17:19:12 -06:00
Alex Lovell-Troy
ee1fc327e2
feat(secrets): implement SecretStore interface and StaticStore/LocalStore for credential management 2025-04-26 17:19:12 -06:00
ccce61694b
fix: changed 'update' cmd to use gofish 2025-04-26 17:17:04 -06:00
e19af0ce0c
refactor: moved internal functions to pkg and updated refs 2025-04-26 17:15:16 -06:00
Alex Lovell-Troy
03c54cc7c1
chore: update golang.org/x/crypto and golang.org/x/sys dependencies to latest versions 2025-04-26 17:03:15 -06:00
Alex Lovell-Troy
b31ed136f6
feat: add --insecure flag to allow insecure connections for firmware updates 2025-04-26 17:03:15 -06:00
Alex Lovell-Troy
03bf2250a4
refactor: improve Redfish service connection handling and update status retrieval 2025-04-26 16:59:36 -06:00
Alex Lovell-Troy
51c01df73a
feat: enhance firmware update functionality and add BMC identification support 2025-04-26 16:59:36 -06:00
David Allen
7498aa5890
fix: change db.MustExec to db.Exec and handle error 2025-04-26 16:58:58 -06:00
Alex Lovell-Troy
4e7d011cd0
Update prbuild.yml
Signed-off-by: Alex Lovell-Troy <alovelltroy@lanl.gov>
2025-04-26 16:58:58 -06:00
Alex Lovell-Troy
3682bbdcc7
chore: update build workflow and add container build script (#70)
* chore: update build workflow and add container build script
* Add build dependencies to workflow
* fix: remove unnecessary magellan installation path from goreleaser config
2025-04-26 16:58:58 -06:00
35 changed files with 1936 additions and 301 deletions

View file

@ -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

View file

@ -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
id: goreleaser

View file

@ -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 <allend@lanl.gov>"
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 <allend@lanl.gov>"
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'

View file

@ -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

147
README.md
View file

@ -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.**
<!-- TOC start (generated with https://github.com/derlin/bitdowntoc) -->
* [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)
<!-- TOC end -->
<!-- TOC --><a name="openchami-magellan"></a>
## 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

60
build-in-container.sh Normal file
View file

@ -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"

View file

@ -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"
)

View file

@ -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")))

View file

@ -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)

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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 {

277
cmd/secrets.go Normal file
View file

@ -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 <basic(default)|json|base64>",
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()))
}

View file

@ -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")))

View file

@ -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

12
go.mod
View file

@ -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

11
go.sum
View file

@ -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=

View file

@ -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)

View file

@ -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
}

47
internal/util/bmc.go Normal file
View file

@ -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
}

65
pkg/bmc/bmc.go Normal file
View file

@ -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
}

View file

@ -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,
})

69
pkg/crawler/identify.go Normal file
View file

@ -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
}

View file

@ -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
}
}

View file

@ -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

75
pkg/secrets/encryption.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

212
pkg/secrets/example/main.go Normal file
View file

@ -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=<your master key> to use the same key in the next commands.")
fmt.Println()
fmt.Println(" go run main.go store <secretID> <secretValue> [filename]")
fmt.Println(" - Stores the given string value under secretID.")
fmt.Println()
fmt.Println(" go run main.go storebase64 <secretID> <base64String> [filename]")
fmt.Println(" - Decodes the base64-encoded string before storing.")
fmt.Println()
fmt.Println(" go run main.go storejson <secretID> <jsonString> [filename]")
fmt.Println(" - Stores the provided JSON for the specified secretID.")
fmt.Println()
fmt.Println(" go run main.go retrieve <secretID> [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 <secretID> <secretValue> [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 <secretID> <base64String> [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 <secretID> '{"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 <secretID> [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()
}
}

161
pkg/secrets/localstore.go Normal file
View file

@ -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
}

View file

@ -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])
}
}

10
pkg/secrets/main.go Normal file
View file

@ -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
}

View file

@ -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
}

103
pkg/update.go Normal file
View file

@ -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
}

View file

@ -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"
)

View file

@ -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,
},
)