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
This commit is contained in:
+141
-3
@@ -1,9 +1,15 @@
|
||||
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
|
||||
@@ -14,6 +20,10 @@ import (
|
||||
// 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 {
|
||||
@@ -31,15 +41,21 @@ func CheckKeyRef(ref string) error {
|
||||
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:, or bw: prefixes (unrecognized ref redacted)")
|
||||
return fmt.Errorf("key ref must use env:, file:, literal:, mpk:, or bw: prefixes (unrecognized ref redacted)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveKeyRef resolves a key reference to its value. Never log the result.
|
||||
// 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
|
||||
@@ -63,13 +79,135 @@ func ResolveKeyRef(ref string) (string, error) {
|
||||
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.
|
||||
// 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] + "****"
|
||||
|
||||
Reference in New Issue
Block a user