Files
mrcharles c869ac1b06 config: keyproxy mpk refs, loop daemon knobs, redmine status map, gitea section
mpk: key refs resolve through the ukrrs/mopac-keyproxy hop ([keyproxy]
url + token_ref, POST /v1/resolve, short in-memory cache); env:/file:/
literal: refs keep working untouched, so the harness runs with or
without keyproxy up. [loop] gains poll_interval_secs + state_dir for the
self-host daemon; [redmine.status_map] (quoted TOML keys) drives status
transitions; [gitea] carries the optional REPORT-commit step, off by
default. All hosts live in harness.toml, none in code.

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
2026-08-28 22:05:41 -05:00

217 lines
6.7 KiB
Go

package config
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
)
// Secret key references. Values are never logged and never stored by the
// harness; only their resolved bytes reach the outgoing HTTP headers.
//
// Formats:
//
// env:NAME environment variable (must be set and non-empty)
// file:PATH file whose trimmed contents are the key
// literal:VALUE inline key (last resort; still never logged)
// mpk:REF keyproxy placeholder (ukrrs/mopac-keyproxy); resolved via
// POST /v1/resolve. A `mpk:` prefix missing its own
// `mpk-` prefix is auto-prefixed ("mpk:redmine" ==
// "mpk:mpk-redmine"). Needs [keyproxy] config (KeyResolver).
// bw:REF bitwarden item (reserved; lands with the bw wrapper, phase 3)
func CheckKeyRef(ref string) error {
switch {
case ref == "":
return fmt.Errorf("empty key reference")
case strings.HasPrefix(ref, "env:"):
if len(ref) <= 4 {
return fmt.Errorf("key ref %q: env: needs a variable name", redact(ref))
}
case strings.HasPrefix(ref, "file:"):
if len(ref) <= 5 {
return fmt.Errorf("key ref %q: file: needs a path", redact(ref))
}
case strings.HasPrefix(ref, "literal:"):
if len(ref) <= 8 {
return fmt.Errorf("key ref: literal: value is empty")
}
case strings.HasPrefix(ref, "mpk:"):
if len(strings.TrimSpace(ref[4:])) == 0 {
return fmt.Errorf("key ref %q: mpk: needs a placeholder name", redact(ref))
}
case strings.HasPrefix(ref, "bw:"):
return fmt.Errorf("key ref bw: not implemented yet (bitwarden wrapper lands in build phase 3)")
default:
return fmt.Errorf("key ref must use env:, file:, literal:, mpk:, or bw: prefixes (unrecognized ref redacted)")
}
return nil
}
// ResolveKeyRef resolves a local key reference (env:, file:, literal:) to its
// value. mpk: refs need the keyproxy hop and a context; use KeyResolver.
// Never log the result.
func ResolveKeyRef(ref string) (string, error) {
if err := CheckKeyRef(ref); err != nil {
return "", err
}
switch {
case strings.HasPrefix(ref, "env:"):
v := os.Getenv(ref[4:])
if v == "" {
return "", fmt.Errorf("environment variable %s is not set", ref[4:])
}
return v, nil
case strings.HasPrefix(ref, "file:"):
data, err := os.ReadFile(ref[5:])
if err != nil {
return "", fmt.Errorf("read key file: %w", err)
}
v := strings.TrimSpace(string(data))
if v == "" {
return "", fmt.Errorf("key file %s is empty", ref[5:])
}
return v, nil
case strings.HasPrefix(ref, "literal:"):
return ref[8:], nil
case strings.HasPrefix(ref, "mpk:"):
return "", fmt.Errorf("key ref mpk: needs the [keyproxy] resolver (KeyResolver), not the local resolver")
}
return "", fmt.Errorf("unreachable key ref branch")
}
// KeyResolver resolves key refs, mpk: included, against the configured
// keyproxy hop ([keyproxy] in harness.toml). mpk resolutions are cached in
// memory for a short TTL so the poll loop does not hammer the proxy; the
// material itself never leaves memory. With no [keyproxy] config, env:/file:/
// literal: refs still resolve — the harness runs with or without keyproxy up.
type KeyResolver struct {
cfg KeyProxyConfig
http *http.Client
ttl time.Duration
mu sync.Mutex
cache map[string]mpkEntry
}
type mpkEntry struct {
value string
expires time.Time
}
// NewKeyResolver builds a resolver from the config's [keyproxy] section.
func NewKeyResolver(cfg *Config) *KeyResolver {
ttl := time.Duration(cfg.KeyProxy.CacheTTLSecs) * time.Second
if ttl <= 0 {
ttl = 60 * time.Second
}
return &KeyResolver{
cfg: cfg.KeyProxy,
http: &http.Client{Timeout: 15 * time.Second},
ttl: ttl,
cache: make(map[string]mpkEntry),
}
}
// Resolve resolves any supported key ref. Never log the result.
func (r *KeyResolver) Resolve(ctx context.Context, ref string) (string, error) {
if strings.HasPrefix(ref, "mpk:") {
return r.resolveMPK(ctx, ref[4:])
}
return ResolveKeyRef(ref)
}
// resolveMPK POSTs {"ref":"mpk-<name>"} to the keyproxy /v1/resolve hop with
// the bearer token from [keyproxy] token_ref (itself a local ref, resolved
// once and cached for the process lifetime).
func (r *KeyResolver) resolveMPK(ctx context.Context, name string) (string, error) {
name = strings.TrimSpace(name)
if !strings.HasPrefix(name, "mpk-") {
name = "mpk-" + name
}
if r.cfg.URL == "" {
return "", fmt.Errorf("key ref mpk: no [keyproxy] url configured (fall back to env:/file: refs, or configure the hop)")
}
r.mu.Lock()
if e, ok := r.cache[name]; ok && time.Now().Before(e.expires) {
r.mu.Unlock()
return e.value, nil
}
r.mu.Unlock()
token, err := ResolveKeyRef(r.cfg.TokenRef)
if err != nil {
return "", fmt.Errorf("keyproxy token (%s): %w", redact(r.cfg.TokenRef), err)
}
body, err := json.Marshal(map[string]string{"ref": name})
if err != nil {
return "", err
}
url := strings.TrimSuffix(r.cfg.URL, "/") + "/v1/resolve"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body)))
if err != nil {
return "", fmt.Errorf("keyproxy request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := r.http.Do(req)
if err != nil {
return "", fmt.Errorf("keyproxy %s: %w", "resolve", redactErr(err))
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("keyproxy resolve: HTTP %d: %s", resp.StatusCode, truncateErrBody(string(raw)))
}
var out struct {
Value string `json:"value"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return "", fmt.Errorf("keyproxy decode: %w", err)
}
if out.Value == "" {
return "", fmt.Errorf("keyproxy resolve: empty value for %s", name)
}
r.mu.Lock()
r.cache[name] = mpkEntry{value: out.Value, expires: time.Now().Add(r.ttl)}
r.mu.Unlock()
return out.Value, nil
}
// redactErr strips any URL query or userinfo from transport errors so a
// misconfigured hop cannot echo the token back into logs.
func redactErr(err error) error {
msg := err.Error()
if i := strings.Index(msg, "?"); i >= 0 {
msg = msg[:i] + "?..."
}
return fmt.Errorf("%s", msg)
}
func truncateErrBody(s string) string {
s = strings.TrimSpace(s)
if len(s) > 200 {
s = s[:200] + "..."
}
return s
}
// redact masks everything after a recognized prefix so malformed refs can
// be reported without echoing a possibly-pasted secret. Refs without a
// recognizable prefix are never echoed at all. mpk: refs are placeholders
// by construction, but they are masked too for uniformity.
func redact(ref string) string {
if i := strings.IndexByte(ref, ':'); i >= 0 && len(ref) > i+1 {
return ref[:i+1] + "****"
}
return "<redacted>"
}