fix(0006f): cluster secret out of argv, migrate-to-kv TLS guard, R1/CA docs (audit 0008 lows)
Low-severity cluster hardening from audit 0008: - Route secret out of argv (N1-low): --cluster-pass and a nats://user:pass@host in --routes are visible in ps/journald. New --cluster-pass-file and the UNIBUS_CLUSTER_PASS env var (precedence file > env > flag); the resolved secret guards the route layer and is injected into bare --routes entries (injectRouteCreds), so peers can be listed as nats://host:6250 with no secret in argv. The legacy --cluster-pass stays for dev/compat. - migrate-to-kv confidentiality (N6): refuse a remote --nats-url without --ca (the allowlist would travel cleartext); loopback targets are exempt (isLoopbackURL). - Docs (N1 route CA, N3 DoS): deploy/README gains a Clustering section — use a SEPARATE cluster CA for routes (not the client CA), keep the secret out of argv, run migrate-to-kv loopback/TLS only, and R1 is a SPOF of auth (not HA); R3 quorum is real HA. The generated cert material lives in deploy/cluster/ (0006g). Tests: - TestResolveClusterPass (file > env > flag precedence; missing file errors), - TestInjectRouteCreds (injects only into userinfo-less routes; preserves overrides), - TestIsLoopbackURL (loopback vs remote vs malformed). CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
@@ -21,6 +23,74 @@ func splitRoutes(csv string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveClusterPass resolves the cluster route secret WITHOUT leaking it through
|
||||
// argv (audit 0008 N1-low: --cluster-pass in argv is visible in ps/journald).
|
||||
// Precedence: --cluster-pass-file (read + trim the file), then the env var
|
||||
// UNIBUS_CLUSTER_PASS, then the legacy --cluster-pass flag (argv-visible, kept for
|
||||
// dev/compat). env is injected (os.Getenv result) so the function stays testable.
|
||||
// It returns the secret and a short source label for logging (never the secret).
|
||||
func resolveClusterPass(passFlag, passFile, env string) (secret, source string, err error) {
|
||||
if passFile != "" {
|
||||
b, rerr := os.ReadFile(passFile)
|
||||
if rerr != nil {
|
||||
return "", "", fmt.Errorf("read --cluster-pass-file %q: %w", passFile, rerr)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), "file", nil
|
||||
}
|
||||
if env != "" {
|
||||
return env, "env", nil
|
||||
}
|
||||
if passFlag != "" {
|
||||
return passFlag, "flag", nil
|
||||
}
|
||||
return "", "none", nil
|
||||
}
|
||||
|
||||
// injectRouteCreds rewrites each route URL that carries NO userinfo to embed
|
||||
// user:pass, so the cluster secret is supplied once (via file/env) instead of
|
||||
// repeated in every --routes argv entry where ps/journald would expose it. A route
|
||||
// that already carries userinfo is left untouched (operator override). With an
|
||||
// empty user it is a no-op. A malformed route URL is an error (configuration bug)
|
||||
// rather than a silently dropped peer.
|
||||
func injectRouteCreds(routes []string, user, pass string) ([]string, error) {
|
||||
if user == "" {
|
||||
return routes, nil
|
||||
}
|
||||
out := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
u, err := url.Parse(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse route %q: %w", r, err)
|
||||
}
|
||||
if u.User == nil {
|
||||
u.User = url.UserPassword(user, pass)
|
||||
}
|
||||
out = append(out, u.String())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isLoopbackURL reports whether a NATS url targets this host only (loopback). Used
|
||||
// to guard migrate-to-kv (audit 0008 N6): pushing the allowlist to a REMOTE NATS
|
||||
// without TLS would send handles/roles/sign-pubs in cleartext, so a remote target
|
||||
// must be TLS-pinned (--ca). A url we cannot classify is treated as NON-loopback
|
||||
// (conservative: it then requires --ca).
|
||||
func isLoopbackURL(natsURL string) bool {
|
||||
u, err := url.Parse(natsURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
switch host {
|
||||
case "localhost":
|
||||
return true
|
||||
case "":
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// isLoopbackBind reports whether the --bind value keeps the service reachable
|
||||
// only from this host. An empty bind means "all interfaces" (public), and a
|
||||
// hostname we cannot resolve to a loopback literal is treated as public — the
|
||||
|
||||
Reference in New Issue
Block a user