Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support HashiCorp Vault with token based access #38

Merged
merged 11 commits into from
Jan 17, 2024
3 changes: 3 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ trim_trailing_whitespace = true

[{*.go,Makefile,go.mod,go.sum}]
indent_style = tab

[*.sh]
indent_size = 4
13 changes: 12 additions & 1 deletion cmd/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ import (
"log/slog"
)

var cfgFilePath string

// backupCmd represents the backup command
var backupCmd = &cobra.Command{
Use: "backup",
Short: "Execute the backup.",
Long: "Execute the backup. Used config can be overridden by providing arguments.",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := config.New()
c, err := config.New(cfgFilePath)
if err != nil {
slog.Error("error while initializing config", "error", err)
return err
Expand Down Expand Up @@ -79,3 +81,12 @@ var backupCmd = &cobra.Command{
return nil
},
}

func init() {
rootCmd.PersistentFlags().StringVar(
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
&cfgFilePath,
"config",
"",
"path to config file (default: ./config.yaml)",
)
}
18 changes: 12 additions & 6 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# In order to enable environmental substitution, it is necessary to include all potential configurations in this
# configuration file. Otherwise, the environment variables will not be automatically added to the ResourceConfig
# (`mapstructure:",remain"`).

loglevel: info

source:
type: filesystem
path: /tmp/source.txt
type:
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
token:
address:
path:

destination:
type: filesystem
path: /tmp/destination.txt

logLevel: debug
type:
path:
21 changes: 21 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,35 @@ module github.com/cluetec/lifeboat
go 1.21

require (
github.com/go-playground/validator/v10 v10.16.0
github.com/hashicorp/vault/api v1.10.0
github.com/mitchellh/mapstructure v1.5.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.17.0
)

require (
github.com/cenkalti/backoff/v3 v3.0.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.6.6 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-sockaddr v1.0.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
Expand All @@ -23,9 +41,12 @@ 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.13.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
74 changes: 74 additions & 0 deletions go.sum

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ func (c *Config) DebugEnabled() bool {
return strings.ToLower(c.LogLevel) == "debug"
}

func New() (*Config, error) {
if err := initViper(); err != nil {
func New(cfgFilePath string) (*Config, error) {
if err := initViper(cfgFilePath); err != nil {
slog.Error("error while initializing viper", "error", err)
return nil, err
}
Expand Down
11 changes: 8 additions & 3 deletions internal/config/viper.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@ import (
"strings"
)

func initViper() error {
func initViper(cfgFilePath string) error {
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AddConfigPath(".")
viper.SetConfigFile("./config.yaml")

if cfgFilePath == "" {
viper.AddConfigPath(".")
viper.SetConfigFile("./config.yaml")
} else {
viper.SetConfigFile(cfgFilePath)
}

if err := viper.ReadInConfig(); err != nil {
slog.Error("error while reading in the configs: %w", err)
Expand Down
70 changes: 70 additions & 0 deletions internal/source/hashicorpvault/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* Copyright 2023 cluetec GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package hashicorpvault

import (
globalConfig "github.com/cluetec/lifeboat/internal/config"
"github.com/go-playground/validator/v10"
vault "github.com/hashicorp/vault/api"
"github.com/mitchellh/mapstructure"
"log/slog"
)

const Type = "hashicorpvault"

type config struct {
Address string `validate:"http_url,required"`
Token string `validate:"required"`
}

var validate *validator.Validate

// newConfig provides the specific `config` struct. Therefor it takes the generic `globalConfig.ResourceConfig` and
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
// decodes it into the `config` struct and validates the values.
func newConfig(rc *globalConfig.ResourceConfig) (*config, error) {
var c config

err := mapstructure.Decode(rc, &c)
if err != nil {
slog.Error("unable to decode config into HashiCorp Vault source config", "error", err)
return nil, err
}

validate = validator.New()
if err := validate.Struct(c); err != nil {
return nil, err
}

return &c, nil
}

// LogValue customizes how the `config` struct will be printed in the logs.
func (c *config) LogValue() slog.Value {
return slog.GroupValue(slog.String("address", c.Address), slog.String("token", "***"))
}

// GetHashiCorpVaultConfig was implement in regard to the
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
// `vault.DefaultConfig()` method. While the implementation the
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
// `config.ReadEnvironment` was left of, to avoid the usage of additional
// environment variables like `VAULT_ADDRESS`.
func (c *config) GetHashiCorpVaultConfig() *vault.Config {
config := vault.Config{
Address: c.Address,
}

return &config
}
75 changes: 75 additions & 0 deletions internal/source/hashicorpvault/reader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright 2023 cluetec GmbH
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package hashicorpvault

import (
globalConfig "github.com/cluetec/lifeboat/internal/config"
vault "github.com/hashicorp/vault/api"
"io"
"log/slog"
)

const snapshotPath = "/sys/storage/raft/snapshot"

// Reader implements the `io.ReaderClose` interface for read the backup from HashiCorp Vault.
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
type Reader struct {
client *vault.Client
reader io.Reader
}

// NewReader initializes a new `Reader` struct which is implementing the `io.ReaderClose` interface.
func NewReader(rc *globalConfig.ResourceConfig) (*Reader, error) {
c, err := newConfig(rc)
if err != nil {
slog.Error("error while initializing source config", "sourceType", "hashicorpvault", "error", err)
return nil, err
}

slog.Debug("source config loaded", "sourceType", Type, "config", rc)

client, err := vault.NewClient(c.GetHashiCorpVaultConfig())
if err != nil {
return nil, err
}

client.SetToken(c.Token)
return &Reader{client: client}, nil
}

func (r *Reader) Read(b []byte) (int, error) {
slog.Debug("hashicorp vault source read got called")

if r.reader == nil {
resp, err := r.client.Logical().ReadRaw(snapshotPath)
if err != nil {
slog.Error("failed to called backup endpoint", "error", err)
return 0, err
}

r.reader = resp.Body
}

return r.reader.Read(b)
}

func (r *Reader) Close() error {
slog.Debug("closing HashiCorp Vault reader")
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
if closer, ok := r.reader.(io.Closer); ok {
return closer.Close()
}
return nil
}
7 changes: 5 additions & 2 deletions internal/source/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"errors"
"github.com/cluetec/lifeboat/internal/config"
"github.com/cluetec/lifeboat/internal/source/filesystem"
"github.com/cluetec/lifeboat/internal/source/hashicorpvault"
"io"
"log/slog"
)
Expand All @@ -32,9 +33,11 @@ func New(c config.SourceConfig) (*Source, error) {
s := Source{}
var err error

switch {
case c.Type == filesystem.Type:
switch c.Type {
case filesystem.Type:
s.Reader, err = filesystem.NewReader(&c.ResourceConfig)
case hashicorpvault.Type:
s.Reader, err = hashicorpvault.NewReader(&c.ResourceConfig)
}
if err != nil {
slog.Error("error while initializing reader interface for source system", "sourceType", c.Type, "error", err)
Expand Down
4 changes: 4 additions & 0 deletions samples/HashiCorp-Vault/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.data/
backup-destination/*.snap
vault-token.txt
vault-unseal-keys.txt
81 changes: 81 additions & 0 deletions samples/HashiCorp-Vault/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Sample: Backup HashiCorp Vault

In this example we will show you how to back up an HashiCorp Vault instance.
a-mesin marked this conversation as resolved.
Show resolved Hide resolved

## Requirements

The Vault instance needs to use [raft](https://developer.hashicorp.com/vault/docs/configuration/storage/raft) as the
underlying storage engine.

## Policy

In this sample we are using for simplicity reasons the "root token" to authorize us. In a real world scenario you
would use a separate took or any other
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
[authentication method supported by vault](https://developer.hashicorp.com/vault/docs/auth).

**What's important here**: Normally your identity shouldn't have root permissions! The only permission you need for
creating the backup/snapshot is the following
([vault policy](https://developer.hashicorp.com/vault/docs/concepts/policies)):

```hcl
path "/sys/storage/raft/snapshot" {
capabilities = ["read"]
}
```

## Run

### 1. Start the vault instance

The HashiCorp Vault setup is based on a docker compose setup.

```shell
# -d is starting the container in the background
$ docker-compose up -d
```

As we can't simply use the dev mode of Vault, we need to initialize and unseal it first. For this purpose the
`docker-compose.yaml` contains next to the vault container an additional one which is called `vault-init`. This
a-mesin marked this conversation as resolved.
Show resolved Hide resolved
container contains the bash script `./init-and-fill-vault.sh` which will do all the necessary steps.

On default, we are storing 1000 secrets with a length of 2000 random chars into vault. As this takes some seconds, you
can verify with this command, if the container has successfully executed the script or not. As a hint, it could take
something around 1 minute until the init script finishes.

- Successful: Status of vault-init container == `Exited (0)`
- Not successful: Status of vault-init container == `Exited (1)`

```shell
$ docker-compose ps --all
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
hashicorp-vault-vault-1 hashicorp/vault:1.15 "vault server -confi…" vault 59 seconds ago Up 58 seconds 0.0.0.0:8200->8200/tcp
hashicorp-vault-vault-init-1 hashicorp-vault-vault-init "bash /init.sh" vault-init 59 seconds ago Exited (0) 5 seconds ago
```

### 3. Run lifeboat to create the backup

As the root token will be randomly generated everytime you are starting a new vault instance, we are storing it in the
file `./vault-token.txt` so that we can use it in lifeboat to successfully authenticate while doing the backup.
Therefor we need to parse the content of this file into an environment variable which will be then used by
lifeboat.

The following command will trigger a backup and will store it in the `./backup-destination` folder:

```shell
$ SOURCE_TOKEN=$(cat ./vault-token.txt) lb backup --config ./backup-config.yaml
```

## Clean up after run

To clean up everything afterwards, we just need to execute the following commands:

```shell
$ docker-compose down
$ rm -rf .data
$ rm -rf backup-destination/vault-backup.snap
```

## Restore

An official guide how to restore a backup/snapshot can be found here:
<https://developer.hashicorp.com/vault/tutorials/standard-procedures/sop-restore>
Loading
Loading