b8201a82cd
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>
96 lines
3.6 KiB
Go
96 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/enmanuel/unibus/pkg/busauth"
|
|
"github.com/enmanuel/unibus/pkg/membership"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// runMigrateCLI implements `membershipd migrate-to-kv`, the idempotent move of
|
|
// the control-plane state from the local SQLite database into replicated
|
|
// JetStream KV (issue 0003c). It backs up the SQLite file first (VACUUM INTO),
|
|
// then connects to the target NATS and copies every room/member/key/user into
|
|
// the KV buckets. Re-running it converges to the same state.
|
|
//
|
|
// It runs on the bus host (no auth on the control-plane side), connecting to the
|
|
// cluster's NATS; --ca pins TLS when the data plane is secured.
|
|
func runMigrateCLI(args []string) {
|
|
fs := flag.NewFlagSet("migrate-to-kv", flag.ExitOnError)
|
|
dbPath := fs.String("db", defaultDBPath, "SQLite database path to migrate FROM")
|
|
natsURL := fs.String("nats-url", "", "NATS url of the cluster to migrate INTO (required)")
|
|
ca := fs.String("ca", "", "CA cert to pin TLS on the NATS connection (optional)")
|
|
replicas := fs.Int("replicas", 1, "KV replication factor (1 for a 1-2 node rollout, 3 for HA quorum)")
|
|
noBackup := fs.Bool("no-backup", false, "skip the SQLite backup before migrating (NOT recommended)")
|
|
_ = fs.Parse(args)
|
|
|
|
if *natsURL == "" {
|
|
fmt.Fprintln(os.Stderr, "membershipd migrate-to-kv: --nats-url is required (the cluster to write the KV buckets into)")
|
|
os.Exit(2)
|
|
}
|
|
// Confidentiality guard (issue 0006f, audit 0008 N6): the migration writes the
|
|
// allowlist (handles, roles, signing pubkeys) into the KV. Against a REMOTE NATS
|
|
// without TLS that metadata would travel in cleartext, so a remote target MUST
|
|
// be TLS-pinned with --ca. A loopback target is local-only and exempt.
|
|
if !isLoopbackURL(*natsURL) && *ca == "" {
|
|
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: refusing to migrate to remote %q without --ca; the allowlist (handles/roles/sign pubs) would travel in cleartext — pin TLS with --ca, or run against a loopback nats-url\n", *natsURL)
|
|
os.Exit(2)
|
|
}
|
|
|
|
// Back up the SQLite database first so a botched migration can be undone.
|
|
var backupPath string
|
|
if !*noBackup {
|
|
bak, err := membership.BackupSQLite(*dbPath)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: backup failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
backupPath = bak
|
|
fmt.Printf("backed up %s -> %s\n", *dbPath, backupPath)
|
|
}
|
|
|
|
// Connect to the target NATS (optionally TLS-pinned to the bus CA).
|
|
natsOpts := []nats.Option{nats.Name("unibus-migrate")}
|
|
if *ca != "" {
|
|
tlsCfg, err := busauth.LoadCATLSConfig(*ca)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: load CA: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
natsOpts = append(natsOpts, nats.Secure(tlsCfg))
|
|
}
|
|
nc, err := nats.Connect(*natsURL, natsOpts...)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: connect %q: %v\n", *natsURL, err)
|
|
os.Exit(1)
|
|
}
|
|
defer nc.Close()
|
|
|
|
js, err := jetstream.New(nc)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: jetstream: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
report, err := membership.MigrateSQLiteToKV(*dbPath, js, membership.JetStreamConfig{
|
|
Replicas: *replicas,
|
|
OpTimeout: 30 * time.Second,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
report.BackupPath = backupPath
|
|
|
|
fmt.Printf("migrated to KV (replicas=%d): %d rooms, %d members, %d keys, %d users\n",
|
|
*replicas, report.Rooms, report.Members, report.Keys, report.Users)
|
|
if backupPath != "" {
|
|
fmt.Printf("rollback: restore %s if needed\n", backupPath)
|
|
}
|
|
}
|