feat: run TreatVault as a service image
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m2s
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 6m2s
This commit is contained in:
50
.gitea/workflows/main-image.yml
Normal file
50
.gitea/workflows/main-image.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Build and Push Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY_HOST: registry.campbellwireless.net
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
|
||||||
|
git config http.extraheader "Authorization: Basic $(printf '%s' '${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}' | base64)"
|
||||||
|
git fetch --depth=1 origin "${{ github.sha }}"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Compute image tags
|
||||||
|
id: tags
|
||||||
|
run: echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_HOST }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ steps.tags.outputs.sha_short }}
|
||||||
@@ -13,4 +13,4 @@ RUN apk add --no-cache ca-certificates docker-cli age
|
|||||||
COPY --from=build /treatvault /usr/local/bin/treatvault
|
COPY --from=build /treatvault /usr/local/bin/treatvault
|
||||||
EXPOSE 9090
|
EXPOSE 9090
|
||||||
ENTRYPOINT ["/usr/local/bin/treatvault"]
|
ENTRYPOINT ["/usr/local/bin/treatvault"]
|
||||||
CMD ["serve", "--file", "/var/lib/treatvault/secrets.age", "--identity", "/run/secrets/barkstack_treatvault_identity", "--listen", ":9090"]
|
CMD ["--file", "/var/lib/treatvault/secrets.age", "--identity", "/run/secrets/barkstack_treatvault_identity", "--listen", ":9090"]
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -10,17 +10,18 @@ TreatVault manages Barkstack's namespaced secrets. It keeps an age-encrypted sou
|
|||||||
- Consumers mount secrets at the stable target path `barkstack_<name>`; rotations swap the mounted source and update the service, leaving a rollback path.
|
- Consumers mount secrets at the stable target path `barkstack_<name>`; rotations swap the mounted source and update the service, leaving a rollback path.
|
||||||
- Obsolete Barkstack-managed secret objects are deleted; objects still mounted by a managed service are reported in the sync status and refused for deletion via the API until their consumers are updated.
|
- Obsolete Barkstack-managed secret objects are deleted; objects still mounted by a managed service are reported in the sync status and refused for deletion via the API until their consumers are updated.
|
||||||
|
|
||||||
## CLI
|
## Service image
|
||||||
|
|
||||||
|
TreatVault is a service, not a user CLI. `registry.campbellwireless.net/barkstack/treatvault:latest` runs its HTTP API on port `9090` inside the Barkstack overlay network. It accepts only service flags:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
treatvault keygen --identity ./treatvault-identity.txt
|
treatvault --file /var/lib/treatvault/secrets.age \
|
||||||
treatvault init --file ./secrets.age --identity ./treatvault-identity.txt
|
--identity /run/secrets/barkstack_treatvault_identity \
|
||||||
treatvault set --name database_password --file ./secrets.age --identity ./treatvault-identity.txt # value on stdin
|
--listen :9090
|
||||||
treatvault list --file ./secrets.age --identity ./treatvault-identity.txt
|
|
||||||
treatvault delete --name database_password --file ./secrets.age --identity ./treatvault-identity.txt
|
|
||||||
treatvault serve --file ./secrets.age --identity ./treatvault-identity.txt --listen :9090
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On startup TreatVault creates the encrypted vault file when it is absent, using the mounted age identity. It then loads the file, reconciles Docker Swarm secrets, and exposes the API. The identity remains a Docker secret; TreatVault never exposes it through the API or UI.
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
- `GET /barkstack/ui/manifest.json` - Barkstack plugin manifest (id `treatvault`, mount `/treatvault`).
|
- `GET /barkstack/ui/manifest.json` - Barkstack plugin manifest (id `treatvault`, mount `/treatvault`).
|
||||||
@@ -31,7 +32,7 @@ treatvault serve --file ./secrets.age --identity ./treatvault-identity.txt --lis
|
|||||||
|
|
||||||
## Barkstack deployment
|
## Barkstack deployment
|
||||||
|
|
||||||
Add a `treatvault` block to the Barkfile and run `barkstack init` on a Swarm manager:
|
Configure TreatVault in the Barkfile and run `barkstack init` on a Swarm manager:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
treatvault {
|
treatvault {
|
||||||
@@ -40,12 +41,12 @@ treatvault {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`barkstack init` provisions the `barkstack-treatvault` service (manager-constrained) with the encrypted file bind-mounted read-write at its host path, the Docker socket, the `barkstack_treatvault_identity` secret mounted at `/run/secrets/barkstack_treatvault_identity`, and no published ports; the console reaches it over the overlay network at `:9090`.
|
`barkstack init` creates `barkstack_treatvault_identity` if absent, provisions the manager-constrained `barkstack-treatvault` service, and bind-mounts the encrypted file read-write at its configured host path. The service receives only that file, the Docker socket, and its Docker-secret identity; it has no published ports. The console reaches it over the overlay network at `:9090`.
|
||||||
|
|
||||||
|
The encrypted file is safe to commit and back up. Keep its mounted Docker-secret identity outside source control. Use the TreatVault page in the Barkstack Console to create, rotate, and delete secret values; values are write-only and never returned.
|
||||||
|
|
||||||
Services that consume vault secrets carry the `io.barkstack.treatvault.secrets=true` label plus an `io.barkstack.treatvault.names` label listing the logical names they need; `barkstack init` labels the PawSQL service automatically. Each sync mounts the current revision at the stable `/run/secrets/barkstack_<name>` target, so consumers read the same path across rotations.
|
Services that consume vault secrets carry the `io.barkstack.treatvault.secrets=true` label plus an `io.barkstack.treatvault.names` label listing the logical names they need; `barkstack init` labels the PawSQL service automatically. Each sync mounts the current revision at the stable `/run/secrets/barkstack_<name>` target, so consumers read the same path across rotations.
|
||||||
|
|
||||||
The Barkstack console automatically gains a TreatVault page (plugin id `treatvault`) for creating, rotating, and deleting secrets. Deletion is refused with `409 Conflict` while a labeled service still mounts the secret.
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// TreatVault is the Barkstack secret-management service. It is launched by its
|
||||||
|
// container image; operator workflows use Barkstack and the console plugin.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +13,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"sort"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,143 +22,57 @@ import (
|
|||||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type options struct {
|
||||||
|
filePath string
|
||||||
|
identityPath string
|
||||||
|
listen string
|
||||||
|
dockerPath string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
if err := run(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
|
if err := run(ctx, os.Args[1:], os.Stderr); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "treatvault:", err)
|
fmt.Fprintln(os.Stderr, "treatvault:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
func run(ctx context.Context, args []string, stderr io.Writer) error {
|
||||||
if len(args) == 0 {
|
options, err := parseOptions(args, stderr)
|
||||||
return usage(stderr)
|
if err != nil {
|
||||||
}
|
return err
|
||||||
switch args[0] {
|
|
||||||
case "keygen":
|
|
||||||
return runKeygen(args[1:], stdout, stderr)
|
|
||||||
case "init":
|
|
||||||
return runInit(args[1:], stderr)
|
|
||||||
case "set":
|
|
||||||
return runSet(args[1:], stdin, stderr)
|
|
||||||
case "delete":
|
|
||||||
return runDelete(args[1:], stderr)
|
|
||||||
case "list":
|
|
||||||
return runList(args[1:], stdout, stderr)
|
|
||||||
case "serve":
|
|
||||||
return runServe(ctx, args[1:], stderr)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown command %q", args[0])
|
|
||||||
}
|
}
|
||||||
|
return runServer(ctx, options, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runKeygen(args []string, stdout, stderr io.Writer) error {
|
func parseOptions(args []string, stderr io.Writer) (options, error) {
|
||||||
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
|
flags := flag.NewFlagSet("treatvault", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity output path")
|
options := options{}
|
||||||
if err := parseNoArguments(flags, args); err != nil {
|
flags.StringVar(&options.filePath, "file", "/var/lib/treatvault/secrets.age", "encrypted secret file")
|
||||||
return err
|
flags.StringVar(&options.identityPath, "identity", "/run/secrets/barkstack_treatvault_identity", "age identity file")
|
||||||
|
flags.StringVar(&options.listen, "listen", ":9090", "HTTP listen address")
|
||||||
|
flags.StringVar(&options.dockerPath, "docker", "docker", "Docker CLI path")
|
||||||
|
if err := flags.Parse(args); err != nil {
|
||||||
|
return options, err
|
||||||
}
|
}
|
||||||
recipient, err := vault.GenerateIdentity(*identityPath)
|
if flags.NArg() != 0 {
|
||||||
if err != nil {
|
return options, fmt.Errorf("unexpected arguments: %v", flags.Args())
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(stdout, "Identity written to %s\nRecipient: %s\n", *identityPath, recipient)
|
return options, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInit(args []string, stderr io.Writer) error {
|
func runServer(ctx context.Context, options options, stderr io.Writer) error {
|
||||||
flags, filePath, identityPath := fileFlags("init", stderr)
|
store, err := vault.OpenOrInitialize(options.filePath, options.identityPath)
|
||||||
if err := parseNoArguments(flags, args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
identity, err := vault.ReadIdentity(*identityPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return vault.Initialize(*filePath, identity)
|
secretManager := manager.New(store, provider.Docker{Runner: provider.ExecRunner{Path: options.dockerPath}}, 500*time.Millisecond)
|
||||||
}
|
|
||||||
|
|
||||||
func runSet(args []string, stdin io.Reader, stderr io.Writer) error {
|
|
||||||
flags, filePath, identityPath := fileFlags("set", stderr)
|
|
||||||
name := flags.String("name", "", "short secret name")
|
|
||||||
valuePath := flags.String("value-file", "-", "secret value file, or - for stdin")
|
|
||||||
if err := parseNoArguments(flags, args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if *name == "" {
|
|
||||||
return errors.New("--name is required")
|
|
||||||
}
|
|
||||||
value, err := readValue(*valuePath, stdin)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
store, err := vault.Open(*filePath, *identityPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = store.Set(*name, value)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDelete(args []string, stderr io.Writer) error {
|
|
||||||
flags, filePath, identityPath := fileFlags("delete", stderr)
|
|
||||||
name := flags.String("name", "", "short secret name")
|
|
||||||
if err := parseNoArguments(flags, args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if *name == "" {
|
|
||||||
return errors.New("--name is required")
|
|
||||||
}
|
|
||||||
store, err := vault.Open(*filePath, *identityPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = store.Delete(*name)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runList(args []string, stdout, stderr io.Writer) error {
|
|
||||||
flags, filePath, identityPath := fileFlags("list", stderr)
|
|
||||||
if err := parseNoArguments(flags, args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
store, err := vault.Open(*filePath, *identityPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
snapshot, err := store.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(snapshot.Secrets))
|
|
||||||
for name := range snapshot.Secrets {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
for _, name := range names {
|
|
||||||
_, _ = fmt.Fprintln(stdout, name)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runServe(ctx context.Context, args []string, stderr io.Writer) error {
|
|
||||||
flags, filePath, identityPath := fileFlags("serve", stderr)
|
|
||||||
listen := flags.String("listen", ":9090", "HTTP listen address")
|
|
||||||
dockerPath := flags.String("docker", "docker", "Docker CLI path")
|
|
||||||
if err := parseNoArguments(flags, args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
store, err := vault.Open(*filePath, *identityPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
secretManager := manager.New(store, provider.Docker{Runner: provider.ExecRunner{Path: *dockerPath}}, 500*time.Millisecond)
|
|
||||||
go secretManager.Run(ctx)
|
go secretManager.Run(ctx)
|
||||||
listener, err := net.Listen("tcp", *listen)
|
listener, err := net.Listen("tcp", options.listen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listen on %s: %w", *listen, err)
|
return fmt.Errorf("listen on %s: %w", options.listen, err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
logger := slog.New(slog.NewTextHandler(stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||||
logger.Info("TreatVault listening", "address", listener.Addr().String())
|
logger.Info("TreatVault listening", "address", listener.Addr().String())
|
||||||
@@ -183,39 +98,3 @@ func runServe(ctx context.Context, args []string, stderr io.Writer) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fileFlags(name string, stderr io.Writer) (*flag.FlagSet, *string, *string) {
|
|
||||||
flags := flag.NewFlagSet(name, flag.ContinueOnError)
|
|
||||||
flags.SetOutput(stderr)
|
|
||||||
filePath := flags.String("file", "secrets.age", "encrypted secret file")
|
|
||||||
identityPath := flags.String("identity", "treatvault-identity.txt", "age identity file")
|
|
||||||
return flags, filePath, identityPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNoArguments(flags *flag.FlagSet, args []string) error {
|
|
||||||
if err := flags.Parse(args); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flags.NArg() != 0 {
|
|
||||||
return fmt.Errorf("unexpected arguments: %v", flags.Args())
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readValue(path string, stdin io.Reader) ([]byte, error) {
|
|
||||||
if path == "-" {
|
|
||||||
return io.ReadAll(io.LimitReader(stdin, 500*1024+1))
|
|
||||||
}
|
|
||||||
return os.ReadFile(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func usage(stderr io.Writer) error {
|
|
||||||
_, _ = fmt.Fprintln(stderr, "usage:")
|
|
||||||
_, _ = fmt.Fprintln(stderr, " treatvault keygen [--identity FILE]")
|
|
||||||
_, _ = fmt.Fprintln(stderr, " treatvault init [--file FILE] [--identity FILE]")
|
|
||||||
_, _ = fmt.Fprintln(stderr, " treatvault set --name NAME [--value-file FILE|-] [--file FILE] [--identity FILE]")
|
|
||||||
_, _ = fmt.Fprintln(stderr, " treatvault delete --name NAME [--file FILE] [--identity FILE]")
|
|
||||||
_, _ = fmt.Fprintln(stderr, " treatvault list [--file FILE] [--identity FILE]")
|
|
||||||
_, _ = fmt.Fprintln(stderr, " treatvault serve [--file FILE] [--identity FILE] [--listen :9090] [--docker docker]")
|
|
||||||
return errors.New("a command is required")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,85 +2,35 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"cloud.campbellwireless.net/git/barkstack/treatvault/internal/vault"
|
|
||||||
"filippo.io/age"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLIEndToEndWithoutDocker(t *testing.T) {
|
func TestParseOptionsUsesContainerDefaults(t *testing.T) {
|
||||||
directory := t.TempDir()
|
options, err := parseOptions(nil, &bytes.Buffer{})
|
||||||
filePath := filepath.Join(directory, "secrets.age")
|
|
||||||
identityPath := filepath.Join(directory, "identity.txt")
|
|
||||||
|
|
||||||
var stdout, stderr strings.Builder
|
|
||||||
mustRun(t, []string{"keygen", "--identity", identityPath}, nil, &stdout, &stderr)
|
|
||||||
if !strings.Contains(stdout.String(), "Recipient: age1") {
|
|
||||||
t.Fatalf("keygen output = %q", stdout.String())
|
|
||||||
}
|
|
||||||
mustRun(t, []string{"init", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
|
|
||||||
mustRun(t, []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader("correct horse battery staple"), &strings.Builder{}, &strings.Builder{})
|
|
||||||
var list strings.Builder
|
|
||||||
mustRun(t, []string{"list", "--file", filePath, "--identity", identityPath}, nil, &list, &strings.Builder{})
|
|
||||||
if got := strings.TrimSpace(list.String()); got != "database_password" {
|
|
||||||
t.Fatalf("list = %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot, err := vault.Open(filePath, identityPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
loaded, err := snapshot.Load()
|
if options.filePath != "/var/lib/treatvault/secrets.age" || options.identityPath != "/run/secrets/barkstack_treatvault_identity" || options.listen != ":9090" || options.dockerPath != "docker" {
|
||||||
if err != nil {
|
t.Fatalf("options = %#v", options)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if string(loaded.Secrets["database_password"].Value) != "correct horse battery staple" {
|
|
||||||
t.Fatalf("stored value = %q", loaded.Secrets["database_password"].Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
mustRun(t, []string{"delete", "--name", "database_password", "--file", filePath, "--identity", identityPath}, nil, &strings.Builder{}, &strings.Builder{})
|
|
||||||
loaded, err = snapshot.Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(loaded.Secrets) != 0 {
|
|
||||||
t.Fatalf("secrets after delete = %#v", loaded.Secrets)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetRejectsEmptyValue(t *testing.T) {
|
func TestParseOptionsAcceptsServiceOverrides(t *testing.T) {
|
||||||
directory := t.TempDir()
|
options, err := parseOptions([]string{"--file", "/vault/secrets.age", "--identity", "/identity", "--listen", "127.0.0.1:9090", "--docker", "/usr/bin/docker"}, &bytes.Buffer{})
|
||||||
filePath := filepath.Join(directory, "secrets.age")
|
|
||||||
identityPath := filepath.Join(directory, "identity.txt")
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(identityPath, []byte(identity.String()), 0o600); err != nil {
|
if options.filePath != "/vault/secrets.age" || options.identityPath != "/identity" || options.listen != "127.0.0.1:9090" || options.dockerPath != "/usr/bin/docker" {
|
||||||
t.Fatal(err)
|
t.Fatalf("options = %#v", options)
|
||||||
}
|
|
||||||
if err := vault.Initialize(filePath, identity); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var stderr strings.Builder
|
|
||||||
err = run(context.Background(), []string{"set", "--name", "database_password", "--value-file", "-", "--file", filePath, "--identity", identityPath}, strings.NewReader(""), &strings.Builder{}, &stderr)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
|
||||||
t.Fatalf("set empty value error = %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustRun(t *testing.T, args []string, stdin *strings.Reader, stdout, stderr *strings.Builder) {
|
func TestParseOptionsRejectsLegacyCommands(t *testing.T) {
|
||||||
t.Helper()
|
for _, command := range []string{"keygen", "init", "set", "delete", "list", "serve"} {
|
||||||
var reader io.Reader = bytes.NewReader(nil)
|
_, err := parseOptions([]string{command}, &bytes.Buffer{})
|
||||||
if stdin != nil {
|
if err == nil || !strings.Contains(err.Error(), "unexpected arguments") {
|
||||||
reader = stdin
|
t.Errorf("parseOptions(%q) error = %v", command, err)
|
||||||
}
|
}
|
||||||
if err := run(context.Background(), args, reader, stdout, stderr); err != nil {
|
|
||||||
t.Fatalf("run(%v) error = %v", args, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ const (
|
|||||||
maxDocumentBytes = 16 * 1024 * 1024
|
maxDocumentBytes = 16 * 1024 * 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrSecretNotFound = errors.New("secret not found")
|
var (
|
||||||
|
ErrSecretNotFound = errors.New("secret not found")
|
||||||
|
ErrAlreadyInitialized = errors.New("encrypted file already exists")
|
||||||
|
)
|
||||||
|
|
||||||
type Record struct {
|
type Record struct {
|
||||||
Revision string `json:"revision"`
|
Revision string `json:"revision"`
|
||||||
@@ -49,12 +52,26 @@ type Store struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(path, identityPath string) (*Store, error) {
|
// OpenOrInitialize opens an encrypted file, creating an empty vault when it
|
||||||
|
// does not exist. A TreatVault service owns this bootstrap path; operators
|
||||||
|
// never need a separate vault-management executable.
|
||||||
|
func OpenOrInitialize(path, identityPath string) (*Store, error) {
|
||||||
identity, err := ReadIdentity(identityPath)
|
identity, err := ReadIdentity(identityPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
store := &Store{path: path, identity: identity, recipient: identity.Recipient()}
|
store := New(path, identity)
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
if _, err := store.Load(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, fmt.Errorf("stat encrypted file: %w", err)
|
||||||
|
}
|
||||||
|
if err := Initialize(path, identity); err != nil && !errors.Is(err, ErrAlreadyInitialized) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if _, err := store.Load(); err != nil {
|
if _, err := store.Load(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -77,25 +94,9 @@ func ReadIdentity(path string) (*age.X25519Identity, error) {
|
|||||||
return identity, nil
|
return identity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateIdentity(path string) (string, error) {
|
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("generate age identity: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
return "", fmt.Errorf("identity file %q already exists", path)
|
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if err := writeFileAtomic(path, []byte(identity.String()+"\n"), 0o600); err != nil {
|
|
||||||
return "", fmt.Errorf("write age identity: %w", err)
|
|
||||||
}
|
|
||||||
return identity.Recipient().String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Initialize(path string, identity *age.X25519Identity) error {
|
func Initialize(path string, identity *age.X25519Identity) error {
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
return fmt.Errorf("encrypted file %q already exists", path)
|
return fmt.Errorf("%w: %q", ErrAlreadyInitialized, path)
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,23 +86,29 @@ func TestStoreRejectsWrongIdentityAndInvalidValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateIdentityDoesNotOverwrite(t *testing.T) {
|
func TestOpenOrInitializeCreatesReusableEmptyVault(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "identity.txt")
|
identity, err := age.GenerateX25519Identity()
|
||||||
recipient, err := GenerateIdentity(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if recipient == "" {
|
directory := t.TempDir()
|
||||||
t.Fatal("recipient is empty")
|
identityPath := filepath.Join(directory, "identity.txt")
|
||||||
|
if err := os.WriteFile(identityPath, []byte(identity.String()), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := GenerateIdentity(path); err == nil {
|
path := filepath.Join(directory, "state", "secrets.age")
|
||||||
t.Fatal("GenerateIdentity() overwrote existing identity")
|
store, err := OpenOrInitialize(path, identityPath)
|
||||||
}
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if info.Mode().Perm() != 0o600 {
|
snapshot, err := store.Load()
|
||||||
t.Fatalf("identity permissions = %o", info.Mode().Perm())
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(snapshot.Secrets) != 0 {
|
||||||
|
t.Fatalf("initial secrets = %#v", snapshot.Secrets)
|
||||||
|
}
|
||||||
|
if _, err := OpenOrInitialize(path, identityPath); err != nil {
|
||||||
|
t.Fatalf("OpenOrInitialize existing vault: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user