~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
327 lines · 8.2 KB Raw History
package guestagent

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/fsnotify/fsnotify"
)

const (
	maxSecretsCiphertext = 65536

	maxSecretEntries = 256

	defaultSecretMode = 0o600
)

type secretEntry struct {
	Path string `json:"path"`

	Value string `json:"value"`

	Mode string `json:"mode,omitempty"`
}

type secretsRequestedRecord struct {
	Type string `json:"$type"`

	CreatedAt string `json:"createdAt"`
}

type secretsAppliedRecord struct {
	Type string `json:"$type"`

	Via string `json:"via"`

	Count int `json:"count"`

	CreatedAt string `json:"createdAt"`
}

func (a *Agent) readyPath() string { return filepath.Join(a.cfg.StateDir, SecretsReadyFile) }

func (a *Agent) requestPath() string { return filepath.Join(a.cfg.StateDir, SecretsRequestFile) }

func (a *Agent) runSecrets(ctx context.Context) {
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		a.log.Error("the secrets watcher cannot start", "err", err.Error())
		return
	}
	defer watcher.Close()
	if err := watcher.Add(a.cfg.StateDir); err != nil {
		a.log.Error("the secrets watcher cannot watch the state dir", "dir", a.cfg.StateDir, "err", err.Error())
		return
	}
	key := publicKeyRecord{Type: PublicKeyNSID, Kind: publicKeyKindAgeX25519, Tag: secretsTag, Key: a.identity.Recipient().String(), CreatedAt: createdAt()}
	a.wg.Go(func() { _ = a.poster.Post(ctx, key.Type, secretsTag, key) })
	a.log.Info("the sealed secrets fallback is enabled", "getSecrets", a.cfg.Secrets.GetSecrets)
	switch via, count, ok := a.readReady(); {
	case ok && via == secretsViaGetSecrets:
		a.postApplied(ctx, via, count)
	case ok:
	case exists(a.requestPath()) || !a.cfg.Secrets.GetSecrets:
		a.postRequest(ctx)
	}
	for {
		select {
		case <-ctx.Done():
			return
		case err, open := <-watcher.Errors:
			if !open {
				return
			}
			a.log.Warn("the secrets watcher reported an error", "err", err.Error())
		case ev, open := <-watcher.Events:
			if !open {
				return
			}
			if !ev.Has(fsnotify.Create) && !ev.Has(fsnotify.Write) {
				continue
			}
			switch filepath.Base(ev.Name) {
			case SecretsRequestFile:
				a.postRequest(ctx)
			case SecretsReadyFile:
				if via, count, ok := a.readReady(); ok && via == secretsViaGetSecrets {
					a.postApplied(ctx, via, count)
				}
			}
		}
	}
}

func exists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

func (a *Agent) readReady() (string, int, bool) {
	raw, err := os.ReadFile(a.readyPath())
	if err != nil {
		return "", 0, false
	}
	fields := strings.Fields(string(raw))
	if len(fields) == 0 {
		return "", 0, false
	}
	count := 0
	if len(fields) > 1 {
		count, _ = strconv.Atoi(fields[1])
	}
	return fields[0], max(count, 0), true
}

func (a *Agent) postRequest(ctx context.Context) {
	a.mu.Lock()
	if a.requested {
		a.mu.Unlock()
		return
	}
	a.requested = true
	a.mu.Unlock()
	record := secretsRequestedRecord{Type: SecretsRequestedNSID, CreatedAt: createdAt()}
	a.log.Info("the guest asks the requester for its sealed secrets bundle")
	a.wg.Go(func() {
		if err := a.poster.Post(ctx, record.Type, "", record); err != nil {
			a.mu.Lock()
			a.requested = false
			a.mu.Unlock()
		}
	})
}

func (a *Agent) postApplied(ctx context.Context, via string, count int) {
	a.mu.Lock()
	if a.applied[via] {
		a.mu.Unlock()
		return
	}
	a.applied[via] = true
	a.mu.Unlock()
	record := secretsAppliedRecord{Type: SecretsAppliedNSID, Via: via, Count: count, CreatedAt: createdAt()}
	a.wg.Go(func() { _ = a.poster.Post(ctx, record.Type, "", record) })
}

func (a *Agent) sealedSecrets(ctx context.Context, seq int64, ciphertext string) {
	if a.cfg.Secrets == nil {
		a.log.Debug("a secrets event for a guest without the sealed fallback", "seq", seq)
		return
	}
	if ciphertext == "" || len(ciphertext) > maxSecretsCiphertext {
		a.log.Debug("a secrets ciphertext is empty or too large", "seq", seq, "bytes", len(ciphertext))
		return
	}
	plaintext, err := open(a.identity, ciphertext)
	if err != nil {
		a.log.Debug("a secrets event does not open with this guest's identity", "seq", seq)
		return
	}
	var entries []secretEntry
	if err := json.Unmarshal(plaintext, &entries); err != nil {
		a.log.Warn("a secrets bundle opened but is not a JSON array of {path, value, mode}", "seq", seq, "bytes", len(plaintext))
		return
	}
	clear(plaintext)
	if err := writeSecrets(entries); err != nil {
		a.log.Error("writing the sealed secrets bundle failed", "seq", seq, "err", err.Error())
		return
	}
	if err := writeReady(a.readyPath(), secretsViaSealed, len(entries)); err != nil {
		a.log.Error("marking the secrets ready failed", "err", err.Error())
		return
	}
	a.log.Info("the sealed secrets bundle is written", "seq", seq, "files", len(entries))
	a.mu.Lock()
	a.applied[secretsViaSealed] = false
	a.mu.Unlock()
	a.postApplied(ctx, secretsViaSealed, len(entries))
}

func secretMode(raw string) (os.FileMode, error) {
	if strings.TrimSpace(raw) == "" {
		return defaultSecretMode, nil
	}
	value, err := strconv.ParseUint(strings.TrimSpace(raw), 8, 32)
	if err != nil || value&^defaultSecretMode != 0 {
		return 0, fmt.Errorf("mode %q is not an octal mode of at most 0600", raw)
	}
	return os.FileMode(value), nil
}

func checkSecretPath(path string) error {
	switch {
	case path == "" || !filepath.IsAbs(path):
		return fmt.Errorf("path %q is not absolute", path)
	case filepath.Clean(path) != path || strings.Contains(path, "/../") || strings.HasSuffix(path, "/.."):
		return fmt.Errorf("path %q is not a clean path", path)
	case path == "/" || strings.ContainsRune(path, 0):
		return fmt.Errorf("path %q names no file", path)
	}
	return nil
}

func writeSecrets(entries []secretEntry) error {
	if len(entries) > maxSecretEntries {
		return fmt.Errorf("the bundle names %d files, more than %d", len(entries), maxSecretEntries)
	}
	modes := make([]os.FileMode, len(entries))
	for i, entry := range entries {
		if err := checkSecretPath(entry.Path); err != nil {
			return fmt.Errorf("entry %d: %w", i, err)
		}
		mode, err := secretMode(entry.Mode)
		if err != nil {
			return fmt.Errorf("entry %d: %w", i, err)
		}
		modes[i] = mode
	}
	for i, entry := range entries {
		if err := writeSecretFile(entry.Path, []byte(entry.Value), modes[i]); err != nil {
			return fmt.Errorf("entry %d: %w", i, err)
		}
	}
	return nil
}

func writeSecretFile(path string, value []byte, mode os.FileMode) error {
	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return err
	}
	if info, err := os.Lstat(path); err == nil && !info.Mode().IsRegular() {
		return fmt.Errorf("%s exists and is not a regular file", path)
	} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
		return err
	}
	tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*")
	if err != nil {
		return err
	}
	name := tmp.Name()
	done := false
	defer func() {
		if !done {
			_ = os.Remove(name)
		}
	}()
	if err := tmp.Chmod(mode); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Chown(0, 0); err != nil {
		tmp.Close()
		return err
	}
	if _, err := tmp.Write(value); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Rename(name, path); err != nil {
		return err
	}
	done = true
	return nil
}

func writeReady(path, via string, count int) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return err
	}
	part := path + ".part"
	if err := os.WriteFile(part, []byte(via+" "+strconv.Itoa(count)+"\n"), 0o600); err != nil {
		return err
	}
	return os.Rename(part, path)
}

func WaitSecrets(ctx context.Context, stateDir string) error {
	if err := os.MkdirAll(stateDir, 0o700); err != nil {
		return err
	}
	watcher, err := fsnotify.NewWatcher()
	if err != nil {
		return err
	}
	defer watcher.Close()
	if err := watcher.Add(stateDir); err != nil {
		return err
	}
	ready := filepath.Join(stateDir, SecretsReadyFile)
	if exists(ready) {
		return nil
	}
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case err, open := <-watcher.Errors:
			if !open {
				return errors.New("guestagent: the secrets watcher closed")
			}
			return err
		case ev, open := <-watcher.Events:
			if !open {
				return errors.New("guestagent: the secrets watcher closed")
			}
			if ev.Name == ready && exists(ready) {
				return nil
			}
		}
	}
}