mirror of
https://github.com/davidallendj/magellan.git
synced 2025-12-20 03:27:03 -07:00
Compare commits
83 commits
c0e498766f
...
d34ba3f754
| Author | SHA1 | Date | |
|---|---|---|---|
| d34ba3f754 | |||
| 665cd4bc14 | |||
| a9a3ebee20 | |||
| f3ede4117f | |||
| 0f811543f5 | |||
| 738685095f | |||
| ad30eb297d | |||
| 5d9b382921 | |||
|
|
979841d762 | ||
|
|
ed9db6d943 | ||
|
|
26e39d777b | ||
|
|
5d9afebcb1 | ||
|
|
939be12da7 | ||
|
|
e4a521971a | ||
|
|
7bcd2f9462 | ||
|
|
0ed861e3a7 | ||
|
|
541fb6ebb0 | ||
|
|
0909254550 | ||
|
|
93010587c6 | ||
|
|
e62a38183f | ||
|
|
a6dadfcdb5 | ||
| ee99d6e06d | |||
| 3074e7323a | |||
| 38e22ff24c | |||
| 92b05a81c7 | |||
| b7761c2cbf | |||
| 5b28ea4575 | |||
| e20b6a3b8e | |||
| 5e200edab5 | |||
| 69abd2041d | |||
| 94a339e39e | |||
| 6c5e958863 | |||
| b7cf7233a8 | |||
| 2b0245e17b | |||
| cc112e72e4 | |||
| 5c4ca34976 | |||
| d1042d77aa | |||
| 1ba78539fb | |||
| da8b1a1756 | |||
| 4dd01867f7 | |||
| 5c624de821 | |||
| 6ae0121af7 | |||
| 0333caa403 | |||
| c3e1b40e3b | |||
| 8866dff307 | |||
| 149fcaec6d | |||
| 07e3d0eb7a | |||
| 0569329529 | |||
| d4443ac6c9 | |||
| a4928b9ebb | |||
| b49b1f761d | |||
| 35cf2222a0 | |||
| 9e831914df | |||
| 51ff7b098c | |||
| 22af66f95c | |||
| 842e864384 | |||
| f9059c50a1 | |||
| 9396de11c2 | |||
| cdf380bd64 | |||
|
|
5aacfceb0d | ||
|
|
d45bfa333d | ||
| 397571c441 | |||
|
|
983985464b | ||
|
|
9b1147d177 | ||
| f47869069b | |||
| 0b16bf2ef6 | |||
|
|
33c333a071 | ||
|
|
dd944245c7 | ||
|
|
8b3f02b5b5 | ||
| 40a82d1c66 | |||
| b0ff7a8d38 | |||
|
|
14453bbbaf | ||
| abe0b5e27a | |||
|
|
ee1fc327e2 | ||
| ccce61694b | |||
| e19af0ce0c | |||
|
|
03c54cc7c1 | ||
|
|
b31ed136f6 | ||
|
|
03bf2250a4 | ||
|
|
51c01df73a | ||
|
|
7498aa5890 | ||
|
|
4e7d011cd0 | ||
|
|
3682bbdcc7 |
35 changed files with 1936 additions and 301 deletions
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
9
.github/workflows/prbuild.yml
vendored
9
.github/workflows/prbuild.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -51,7 +51,7 @@ mod: ## go mod tidy
|
|||
inst: ## go install tools
|
||||
$(call print-target)
|
||||
go install github.com/client9/misspell/cmd/misspell@v0.3.4
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.0.1
|
||||
go install github.com/goreleaser/goreleaser/v2@v2.3.2
|
||||
go install github.com/cpuguy83/go-md2man/v2@latest
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ build: ## go build
|
|||
container: ## docker build
|
||||
container:
|
||||
$(call print-target)
|
||||
docker build . --build-arg REGISTRY_HOST=${REGISTRY_HOST} --no-cache --pull --tag '${NAME}:${VERSION}'
|
||||
docker build . --build-arg REGISTRY_HOST=${REGISTRY_HOST} --no-cache --pull --tag '${NAME}:${VERSION}'
|
||||
|
||||
.PHONY: spell
|
||||
spell: ## misspell
|
||||
|
|
|
|||
147
README.md
147
README.md
|
|
@ -2,7 +2,32 @@
|
|||
|
||||
The `magellan` CLI tool is a Redfish-based, board management controller (BMC) discovery tool designed to scan networks and is written in Go. The tool collects information from BMC nodes using the provided Redfish RESTful API with [`gofish`](https://github.com/stmcginnis/gofish) and loads the queried data into an [SMD](https://github.com/davidallendj/smd/tree/master) instance. The tool strives to be more flexible by implementing multiple methods of discovery to work for a wider range of systems (WIP) and is capable of using independently of other tools or services.
|
||||
|
||||
**Note: `magellan` v0.1.0 is incompatible with SMD v2.15.3 and earlier.**
|
||||
> [!NOTE]
|
||||
> The v0.1.0 version of `magellan` is incompatible with `smd` v2.15.3 and earlier due to `smd` lacking the inventory parsing code used with `magellan`'s output.**
|
||||
|
||||
<!-- 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
60
build-in-container.sh
Normal 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"
|
||||
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
122
cmd/collect.go
122
cmd/collect.go
|
|
@ -1,14 +1,17 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/user"
|
||||
|
||||
"github.com/cznic/mathutil"
|
||||
magellan "github.com/davidallendj/magellan/internal"
|
||||
"github.com/davidallendj/magellan/internal/cache/sqlite"
|
||||
urlx "github.com/davidallendj/magellan/internal/url"
|
||||
magellan "github.com/davidallendj/magellan/pkg"
|
||||
"github.com/davidallendj/magellan/pkg/auth"
|
||||
"github.com/davidallendj/magellan/pkg/bmc"
|
||||
"github.com/davidallendj/magellan/pkg/secrets"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
|
@ -18,13 +21,19 @@ import (
|
|||
// This command should be ran after the `scan` to find available hosts
|
||||
// on a subnet.
|
||||
var CollectCmd = &cobra.Command{
|
||||
Use: "collect",
|
||||
Use: "collect",
|
||||
Example: ` // basic collect after scan without making a follow-up request
|
||||
magellan collect --cache ./assets.db --cacert ochami.pem -o ./logs -t 30
|
||||
|
||||
// set username and password for all nodes and make request to specified host
|
||||
magellan collect --host https://smd.openchami.cluster -u $bmc_username -p $bmc_password
|
||||
|
||||
// run a collect using secrets manager with fallback username and password
|
||||
export MASTER_KEY=$(magellan secrets generatekey)
|
||||
magellan secrets store $node_creds_json -f nodes.json
|
||||
magellan collect --host https://smd.openchami.cluster -u $fallback_bmc_username -p $fallback_bmc_password`,
|
||||
Short: "Collect system information by interrogating BMC node",
|
||||
Long: "Send request(s) to a collection of hosts running Redfish services found stored from the 'scan' in cache.\n" +
|
||||
"See the 'scan' command on how to perform a scan.\n\n" +
|
||||
"Examples:\n" +
|
||||
" magellan collect --cache ./assets.db --output ./logs --timeout 30 --cacert cecert.pem\n" +
|
||||
" magellan collect --host smd.example.com --port 27779 --username username --password password",
|
||||
Long: "Send request(s) to a collection of hosts running Redfish services found stored from the 'scan' in cache.\nSee the 'scan' command on how to perform a scan.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// get probe states stored in db from scan
|
||||
scannedResults, err := sqlite.GetScannedAssets(cachePath)
|
||||
|
|
@ -47,18 +56,67 @@ var CollectCmd = &cobra.Command{
|
|||
}
|
||||
}
|
||||
|
||||
if verbose {
|
||||
log.Debug().Str("Access Token", accessToken)
|
||||
}
|
||||
|
||||
//
|
||||
// set the minimum/maximum number of concurrent processes
|
||||
if concurrency <= 0 {
|
||||
concurrency = mathutil.Clamp(len(scannedResults), 1, 10000)
|
||||
}
|
||||
err = magellan.CollectInventory(&scannedResults, &magellan.CollectParams{
|
||||
|
||||
// use secret store for BMC credentials, and/or credential CLI flags
|
||||
var store secrets.SecretStore
|
||||
if username != "" && password != "" {
|
||||
// First, try and load credentials from --username and --password if both are set.
|
||||
log.Debug().Msgf("--username and --password specified, using them for BMC credentials")
|
||||
store = secrets.NewStaticStore(username, password)
|
||||
} else {
|
||||
// Alternatively, locate specific credentials (falling back to default) and override those
|
||||
// with --username or --password if either are passed.
|
||||
log.Debug().Msgf("one or both of --username and --password NOT passed, attempting to obtain missing credentials from secret store at %s", secretsFile)
|
||||
if store, err = secrets.OpenStore(secretsFile); err != nil {
|
||||
log.Error().Err(err).Msg("failed to open local secrets store")
|
||||
}
|
||||
|
||||
// Temporarily override username/password of each BMC if one of those
|
||||
// flags is passed. The expectation is that if the flag is specified
|
||||
// on the command line, it should be used.
|
||||
if username != "" {
|
||||
log.Info().Msg("--username passed, temporarily overriding all usernames from secret store with value")
|
||||
}
|
||||
if password != "" {
|
||||
log.Info().Msg("--password passed, temporarily overriding all passwords from secret store with value")
|
||||
}
|
||||
switch s := store.(type) {
|
||||
case *secrets.StaticStore:
|
||||
if username != "" {
|
||||
s.Username = username
|
||||
}
|
||||
if password != "" {
|
||||
s.Password = password
|
||||
}
|
||||
case *secrets.LocalSecretStore:
|
||||
for k, _ := range s.Secrets {
|
||||
if creds, err := bmc.GetBMCCredentials(store, k); err != nil {
|
||||
log.Error().Str("id", k).Err(err).Msg("failed to override BMC credentials")
|
||||
} else {
|
||||
if username != "" {
|
||||
creds.Username = username
|
||||
}
|
||||
if password != "" {
|
||||
creds.Password = password
|
||||
}
|
||||
|
||||
if newCreds, err := json.Marshal(creds); err != nil {
|
||||
log.Error().Str("id", k).Err(err).Msg("failed to override BMC credentials: marshal error")
|
||||
} else {
|
||||
s.StoreSecretByID(k, string(newCreds))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set the collect parameters from CLI params
|
||||
params := &magellan.CollectParams{
|
||||
URI: host,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Timeout: timeout,
|
||||
Concurrency: concurrency,
|
||||
Verbose: verbose,
|
||||
|
|
@ -66,31 +124,35 @@ var CollectCmd = &cobra.Command{
|
|||
OutputPath: outputPath,
|
||||
ForceUpdate: forceUpdate,
|
||||
AccessToken: accessToken,
|
||||
})
|
||||
SecretStore: store,
|
||||
}
|
||||
|
||||
// show all of the 'collect' parameters being set from CLI if verbose
|
||||
if verbose {
|
||||
log.Debug().Any("params", params)
|
||||
}
|
||||
|
||||
_, err = magellan.CollectInventory(&scannedResults, params)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed to collect data")
|
||||
log.Error().Err(err).Msg("failed to collect data")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
currentUser, _ = user.Current()
|
||||
CollectCmd.PersistentFlags().StringVar(&host, "host", "", "Set the URI to the SMD root endpoint")
|
||||
CollectCmd.PersistentFlags().StringVar(&username, "username", "", "Set the BMC user")
|
||||
CollectCmd.PersistentFlags().StringVar(&password, "password", "", "Set the BMC password")
|
||||
CollectCmd.PersistentFlags().StringVar(&scheme, "scheme", "https", "Set the scheme used to query")
|
||||
CollectCmd.PersistentFlags().StringVar(&protocol, "protocol", "tcp", "Set the protocol used to query")
|
||||
CollectCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", fmt.Sprintf("/tmp/%smagellan/inventory/", currentUser.Username+"/"), "Set the path to store collection data")
|
||||
CollectCmd.PersistentFlags().BoolVar(&forceUpdate, "force-update", false, "Set flag to force update data sent to SMD")
|
||||
CollectCmd.PersistentFlags().StringVar(&cacertPath, "cacert", "", "Path to CA cert. (defaults to system CAs)")
|
||||
|
||||
// set flags to only be used together
|
||||
CollectCmd.MarkFlagsRequiredTogether("username", "password")
|
||||
CollectCmd.Flags().StringVar(&host, "host", "", "Set the URI to the SMD root endpoint")
|
||||
CollectCmd.Flags().StringVarP(&username, "username", "u", "", "Set the master BMC username")
|
||||
CollectCmd.Flags().StringVarP(&password, "password", "p", "", "Set the master BMC password")
|
||||
CollectCmd.Flags().StringVar(&secretsFile, "secrets-file", "", "Set path to the node secrets file")
|
||||
CollectCmd.Flags().StringVar(&scheme, "scheme", "https", "Set the default scheme used to query when not included in URI")
|
||||
CollectCmd.Flags().StringVar(&protocol, "protocol", "tcp", "Set the protocol used to query")
|
||||
CollectCmd.Flags().StringVarP(&outputPath, "output", "o", fmt.Sprintf("/tmp/%smagellan/inventory/", currentUser.Username+"/"), "Set the path to store collection data")
|
||||
CollectCmd.Flags().BoolVar(&forceUpdate, "force-update", false, "Set flag to force update data sent to SMD")
|
||||
CollectCmd.Flags().StringVar(&cacertPath, "cacert", "", "Set the path to CA cert file. (defaults to system CAs when blank)")
|
||||
|
||||
// bind flags to config properties
|
||||
checkBindFlagError(viper.BindPFlag("collect.host", CollectCmd.Flags().Lookup("host")))
|
||||
checkBindFlagError(viper.BindPFlag("collect.username", CollectCmd.Flags().Lookup("username")))
|
||||
checkBindFlagError(viper.BindPFlag("collect.password", CollectCmd.Flags().Lookup("password")))
|
||||
checkBindFlagError(viper.BindPFlag("collect.scheme", CollectCmd.Flags().Lookup("scheme")))
|
||||
checkBindFlagError(viper.BindPFlag("collect.protocol", CollectCmd.Flags().Lookup("protocol")))
|
||||
checkBindFlagError(viper.BindPFlag("collect.output", CollectCmd.Flags().Lookup("output")))
|
||||
|
|
|
|||
76
cmd/crawl.go
76
cmd/crawl.go
|
|
@ -3,10 +3,13 @@ package cmd
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
urlx "github.com/davidallendj/magellan/internal/url"
|
||||
"github.com/davidallendj/magellan/pkg/bmc"
|
||||
"github.com/davidallendj/magellan/pkg/crawler"
|
||||
"github.com/davidallendj/magellan/pkg/secrets"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
|
@ -15,13 +18,11 @@ import (
|
|||
// specfic inventory detail. This command only expects host names and does
|
||||
// not require a scan to be performed beforehand.
|
||||
var CrawlCmd = &cobra.Command{
|
||||
Use: "crawl [uri]",
|
||||
Use: "crawl [uri]",
|
||||
Example: ` magellan crawl https://bmc.example.com
|
||||
magellan crawl https://bmc.example.com -i -u username -p password`,
|
||||
Short: "Crawl a single BMC for inventory information",
|
||||
Long: "Crawl a single BMC for inventory information. This command does NOT store information\n" +
|
||||
"about the scan into cache after completion. To do so, use the 'collect' command instead\n\n" +
|
||||
"Examples:\n" +
|
||||
" magellan crawl https://bmc.example.com\n" +
|
||||
" magellan crawl https://bmc.example.com -i -u username -p password",
|
||||
Long: "Crawl a single BMC for inventory information with URI.\n\n NOTE: This command does not scan subnets, store scan information in cache, nor make a request to a specified host. It is used only to retrieve inventory data directly. Otherwise, use 'scan' and 'collect' instead.",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
// Validate that the only argument is a valid URI
|
||||
var err error
|
||||
|
|
@ -35,19 +36,59 @@ var CrawlCmd = &cobra.Command{
|
|||
return nil
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var (
|
||||
uri = args[0]
|
||||
store secrets.SecretStore
|
||||
err error
|
||||
)
|
||||
|
||||
if username != "" && password != "" {
|
||||
// First, try and load credentials from --username and --password if both are set.
|
||||
log.Debug().Str("id", uri).Msgf("--username and --password specified, using them for BMC credentials")
|
||||
store = secrets.NewStaticStore(username, password)
|
||||
} else {
|
||||
// Alternatively, locate specific credentials (falling back to default) and override those
|
||||
// with --username or --password if either are passed.
|
||||
log.Debug().Str("id", uri).Msgf("one or both of --username and --password NOT passed, attempting to obtain missing credentials from secret store at %s", secretsFile)
|
||||
if store, err = secrets.OpenStore(secretsFile); err != nil {
|
||||
log.Error().Str("id", uri).Err(err).Msg("failed to open local secrets store")
|
||||
}
|
||||
|
||||
// Either none of the flags were passed or only one of them were; get
|
||||
// credentials from secrets store to fill in the gaps.
|
||||
bmcCreds, _ := bmc.GetBMCCredentials(store, uri)
|
||||
nodeCreds := secrets.StaticStore{
|
||||
Username: bmcCreds.Username,
|
||||
Password: bmcCreds.Password,
|
||||
}
|
||||
|
||||
// If either of the flags were passed, override the fetched
|
||||
// credentials with them.
|
||||
if username != "" {
|
||||
log.Info().Str("id", uri).Msg("--username was set, overriding username for this BMC")
|
||||
nodeCreds.Username = username
|
||||
}
|
||||
if password != "" {
|
||||
log.Info().Str("id", uri).Msg("--password was set, overriding password for this BMC")
|
||||
nodeCreds.Password = password
|
||||
}
|
||||
|
||||
store = &nodeCreds
|
||||
}
|
||||
|
||||
systems, err := crawler.CrawlBMCForSystems(crawler.CrawlerConfig{
|
||||
URI: args[0],
|
||||
Username: cmd.Flag("username").Value.String(),
|
||||
Password: cmd.Flag("password").Value.String(),
|
||||
Insecure: cmd.Flag("insecure").Value.String() == "true",
|
||||
URI: uri,
|
||||
CredentialStore: store,
|
||||
Insecure: insecure,
|
||||
UseDefault: true,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Error crawling BMC: %v", err)
|
||||
log.Error().Err(err).Msg("failed to crawl BMC")
|
||||
}
|
||||
// Marshal the inventory details to JSON
|
||||
jsonData, err := json.MarshalIndent(systems, "", " ")
|
||||
if err != nil {
|
||||
fmt.Println("Error marshalling to JSON:", err)
|
||||
log.Error().Err(err).Msg("failed to marshal JSON")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -57,12 +98,11 @@ var CrawlCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
func init() {
|
||||
CrawlCmd.Flags().StringP("username", "u", "", "Set the username for the BMC")
|
||||
CrawlCmd.Flags().StringP("password", "p", "", "Set the password for the BMC")
|
||||
CrawlCmd.Flags().BoolP("insecure", "i", false, "Ignore SSL errors")
|
||||
CrawlCmd.Flags().StringVarP(&username, "username", "u", "", "Set the username for the BMC")
|
||||
CrawlCmd.Flags().StringVarP(&password, "password", "p", "", "Set the password for the BMC")
|
||||
CrawlCmd.Flags().BoolVarP(&insecure, "insecure", "i", false, "Ignore SSL errors")
|
||||
CrawlCmd.Flags().StringVarP(&secretsFile, "file", "f", "nodes.json", "set the secrets file with BMC credentials")
|
||||
|
||||
checkBindFlagError(viper.BindPFlag("crawl.username", CrawlCmd.Flags().Lookup("username")))
|
||||
checkBindFlagError(viper.BindPFlag("crawl.password", CrawlCmd.Flags().Lookup("password")))
|
||||
checkBindFlagError(viper.BindPFlag("crawl.insecure", CrawlCmd.Flags().Lookup("insecure")))
|
||||
|
||||
rootCmd.AddCommand(CrawlCmd)
|
||||
|
|
|
|||
12
cmd/list.go
12
cmd/list.go
|
|
@ -20,13 +20,15 @@ var (
|
|||
// and stored in a cache database from a scan. The data that's stored
|
||||
// is what is consumed by the `collect` command with the --cache flag.
|
||||
var ListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Use: "list",
|
||||
Example: ` magellan list
|
||||
magellan list --cache ./assets.db
|
||||
magellan list --cache-info
|
||||
`,
|
||||
Args: cobra.ExactArgs(0),
|
||||
Short: "List information stored in cache from a scan",
|
||||
Long: "Prints all of the host and associated data found from performing a scan.\n" +
|
||||
"See the 'scan' command on how to perform a scan.\n\n" +
|
||||
"Examples:\n" +
|
||||
" magellan list\n" +
|
||||
" magellan list --cache ./assets.db",
|
||||
"See the 'scan' command on how to perform a scan.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// check if we just want to show cache-related info and exit
|
||||
if showCache {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
42
cmd/scan.go
42
cmd/scan.go
|
|
@ -7,8 +7,8 @@ import (
|
|||
"os"
|
||||
"path"
|
||||
|
||||
magellan "github.com/davidallendj/magellan/internal"
|
||||
"github.com/davidallendj/magellan/internal/cache/sqlite"
|
||||
magellan "github.com/davidallendj/magellan/pkg"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/cznic/mathutil"
|
||||
|
|
@ -33,7 +33,28 @@ var (
|
|||
// See the `ScanForAssets()` function in 'internal/scan.go' for details
|
||||
// related to the implementation.
|
||||
var ScanCmd = &cobra.Command{
|
||||
Use: "scan urls...",
|
||||
Use: "scan urls...",
|
||||
Example: `
|
||||
// assumes host https://10.0.0.101:443
|
||||
magellan scan 10.0.0.101
|
||||
|
||||
// assumes subnet using HTTPS and port 443 except for specified host
|
||||
magellan scan http://10.0.0.101:80 https://user:password@10.0.0.102:443 http://172.16.0.105:8080 --subnet 172.16.0.0/24
|
||||
|
||||
// assumes hosts http://10.0.0.101:8080 and http://10.0.0.102:8080
|
||||
magellan scan 10.0.0.101 10.0.0.102 https://172.16.0.10:443 --port 8080 --protocol tcp
|
||||
|
||||
// assumes subnet using default unspecified subnet-masks
|
||||
magellan scan --subnet 10.0.0.0
|
||||
|
||||
// assumes subnet using HTTPS and port 443 with specified CIDR
|
||||
magellan scan --subnet 10.0.0.0/16
|
||||
|
||||
// assumes subnet using HTTP and port 5000 similar to 192.168.0.0/16
|
||||
magellan scan --subnet 192.168.0.0 --protocol tcp --scheme https --port 5000 --subnet-mask 255.255.0.0
|
||||
|
||||
// assumes subnet without CIDR has a subnet-mask of 255.255.0.0
|
||||
magellan scan --subnet 10.0.0.0/24 --subnet 172.16.0.0 --subnet-mask 255.255.0.0 --cache ./assets.db`,
|
||||
Short: "Scan to discover BMC nodes on a network",
|
||||
Long: "Perform a net scan by attempting to connect to each host and port specified and getting a response.\n" +
|
||||
"Each host is passed *with a full URL* including the protocol and port. Additional subnets can be added\n" +
|
||||
|
|
@ -46,22 +67,7 @@ var ScanCmd = &cobra.Command{
|
|||
"'--protocol' flag.\n\n" +
|
||||
"If the '--disable-probe` flag is used, the tool will not send another request to probe for available.\n" +
|
||||
"Redfish services. This is not recommended, since the extra request makes the scan a bit more reliable\n" +
|
||||
"for determining which hosts to collect inventory data.\n\n" +
|
||||
"Examples:\n" +
|
||||
// assumes host https://10.0.0.101:443
|
||||
" magellan scan 10.0.0.101\n" +
|
||||
// assumes subnet using HTTPS and port 443 except for specified host
|
||||
" magellan scan http://10.0.0.101:80 https://user:password@10.0.0.102:443 http://172.16.0.105:8080 --subnet 172.16.0.0/24\n" +
|
||||
// assumes hosts http://10.0.0.101:8080 and http://10.0.0.102:8080
|
||||
" magellan scan 10.0.0.101 10.0.0.102 https://172.16.0.10:443 --port 8080 --protocol tcp\n" +
|
||||
// assumes subnet using default unspecified subnet-masks
|
||||
" magellan scan --subnet 10.0.0.0\n" +
|
||||
// assumes subnet using HTTPS and port 443 with specified CIDR
|
||||
" magellan scan --subnet 10.0.0.0/16\n" +
|
||||
// assumes subnet using HTTP and port 5000 similar to 192.168.0.0/16
|
||||
" magellan scan --subnet 192.168.0.0 --protocol tcp --scheme https --port 5000 --subnet-mask 255.255.0.0\n" +
|
||||
// assumes subnet without CIDR has a subnet-mask of 255.255.0.0
|
||||
" magellan scan --subnet 10.0.0.0/24 --subnet 172.16.0.0 --subnet-mask 255.255.0.0 --cache ./assets.db\n",
|
||||
"for determining which hosts to collect inventory data.\n\n",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// add default ports for hosts if none are specified with flag
|
||||
if len(ports) == 0 {
|
||||
|
|
|
|||
277
cmd/secrets.go
Normal file
277
cmd/secrets.go
Normal 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()))
|
||||
}
|
||||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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
12
go.mod
|
|
@ -16,10 +16,12 @@ require (
|
|||
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8
|
||||
)
|
||||
|
||||
require github.com/rs/zerolog v1.33.0
|
||||
require (
|
||||
github.com/rs/zerolog v1.33.0
|
||||
golang.org/x/crypto v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
)
|
||||
|
|
@ -28,6 +30,7 @@ require (
|
|||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
|
||||
|
|
@ -49,9 +52,8 @@ require (
|
|||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
11
go.sum
11
go.sum
|
|
@ -123,8 +123,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
|
|||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc=
|
||||
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
|
|
@ -151,8 +151,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
|
|
@ -164,8 +164,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
|
|
|
|||
25
internal/cache/sqlite/sqlite.go
vendored
25
internal/cache/sqlite/sqlite.go
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
47
internal/util/bmc.go
Normal 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
65
pkg/bmc/bmc.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
69
pkg/crawler/identify.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
75
pkg/secrets/encryption.go
Normal 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
|
||||
}
|
||||
41
pkg/secrets/encryption_test.go
Normal file
41
pkg/secrets/encryption_test.go
Normal 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
212
pkg/secrets/example/main.go
Normal 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
161
pkg/secrets/localstore.go
Normal 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
|
||||
}
|
||||
151
pkg/secrets/localstore_test.go
Normal file
151
pkg/secrets/localstore_test.go
Normal 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
10
pkg/secrets/main.go
Normal 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
|
||||
}
|
||||
35
pkg/secrets/staticstore.go
Normal file
35
pkg/secrets/staticstore.go
Normal 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
103
pkg/update.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue