diff --git a/cmd/membershipd/bot_cli.go b/cmd/membershipd/bot_cli.go new file mode 100644 index 00000000..026d845a --- /dev/null +++ b/cmd/membershipd/bot_cli.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/hex" + "errors" + "flag" + "fmt" + "os" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" + "github.com/enmanuel/unibus/pkg/membership" +) + +// runBotCLI implements `membershipd bot add ...`, one-command provisioning of a +// bus identity for an automated process. Where `user add` requires the operator +// to derive a keypair by hand and pass the public key, `bot add` mints the +// identity, registers its signing key in the allowlist, AND writes the bot's +// credentials to a 0600 file the process reads to connect — no manual key +// derivation, no second step. It shares the SQLite/KV store plumbing with the +// user CLI, so `--store kv` provisions against a live cluster the same way. +// +// Like the user CLI it never returns: it exits non-zero on error so it composes +// in shell scripts and systemd ExecStartPre hooks. +func runBotCLI(args []string) { + if len(args) == 0 { + botUsage() + os.Exit(2) + } + sub, rest := args[0], args[1:] + switch sub { + case "add": + botAdd(rest) + case "-h", "--help", "help": + botUsage() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "membershipd bot: unknown subcommand %q\n\n", sub) + botUsage() + os.Exit(2) + } +} + +func botUsage() { + fmt.Fprint(os.Stderr, `usage: membershipd bot add [flags] + +Provision a bus identity for an automated process (a "unibot") in one command: +mint a fresh Ed25519+X25519 identity, register its signing key in the allowlist, +and write the credentials to a 0600 file the process loads to connect. + +required flags: + --handle human-readable name for the bot (shown in the directory) + --out where to write the bot credentials (refused if it exists) + +optional flags: + --role admin or member (default member) + --store sqlite (local DB, default) | kv (the live cluster's allowlist) + --db SQLite database path (--store sqlite; default ./local_files/unibus.db) + +--store kv flags (defaults assume an on-node invocation): + --nats-url cluster NATS (default nats://127.0.0.1:4250) + --internal-id-file persisted internal service identity (default /opt/unibus/secrets/internal.id) + --ca CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt) + --kv-replicas KV replication factor, match the cluster (default 3) + +examples: + membershipd bot add --handle notifier --out ./local_files/notifier.id + membershipd bot add --store kv --handle relay --role member --out /opt/unibus/secrets/relay.id + +The --out file is the canonical identity format read by the worker/clientcheck +clients (pkg/client.LoadIdentity), so the provisioned bot connects with no extra +conversion: point the process at it (e.g. worker --id-file ) and it joins +the bus as this user. +`) +} + +func botAdd(args []string) { + fs := flag.NewFlagSet("bot add", flag.ExitOnError) + handle := fs.String("handle", "", "human-readable bot name (required)") + role := fs.String("role", membership.RoleMember, "role: admin or member") + out := fs.String("out", "", "path to write the bot credentials, 0600 (required)") + dbPath := fs.String("db", defaultDBPath, "SQLite database path") + kf := registerKVFlags(fs) + _ = fs.Parse(args) + + if *handle == "" || *out == "" { + fmt.Fprintln(os.Stderr, "membershipd bot add: --handle and --out are required") + os.Exit(2) + } + + store, kv, closeStore := resolveStore("bot add", kf, *dbPath) + defer closeStore() + + signPubHex, endpoint, err := provisionBot(store, *handle, *role, *out) + if err != nil { + fmt.Fprintf(os.Stderr, "membershipd bot add: %v\n", err) + os.Exit(1) + } + fmt.Printf("provisioned bot %q role=%s\n", *handle, *role) + fmt.Printf(" sign_pub: %s\n", signPubHex) + fmt.Printf(" endpoint: %s\n", endpoint) + fmt.Printf(" credentials: %s (0600)\n", *out) + if kv != nil { + reportKVReplication(kv.js) + } +} + +// provisionBot mints a fresh bus identity and provisions it. It is the generating +// half; provisionBotWithIdentity does the registration + persistence so a test can +// inject a known identity (e.g. to exercise the already-registered error path). +func provisionBot(store membership.Store, handle, role, out string) (signPubHex, endpoint string, err error) { + id, err := cs.GenerateIdentity() + if err != nil { + return "", "", fmt.Errorf("generate bot identity: %w", err) + } + return provisionBotWithIdentity(store, id, handle, role, out) +} + +// provisionBotWithIdentity registers id's signing key under handle/role and writes +// id's credentials to out. It returns the lowercase-hex signing key and the +// derived endpoint id. +// +// Ordering is deliberate so a failure never leaves a half-provisioned bot: +// 1. refuse if out already exists, BEFORE the store is touched (no orphan user); +// 2. register the user — an already-registered key is a clear error, not a panic; +// 3. only then write the 0600 credentials file. +// +// A write failure after a successful register is reported with the registered key +// so the operator can revoke it; this is the one residual non-atomic seam (a +// local admin command, acceptable per KISS). +func provisionBotWithIdentity(store membership.Store, id cs.Identity, handle, role, out string) (signPubHex, endpoint string, err error) { + if handle == "" || out == "" { + return "", "", fmt.Errorf("handle and out are required") + } + if role == "" { + role = membership.RoleMember + } + if _, statErr := os.Stat(out); statErr == nil { + return "", "", fmt.Errorf("out file %q already exists; refusing to overwrite bot credentials", out) + } else if !os.IsNotExist(statErr) { + return "", "", fmt.Errorf("stat out %q: %w", out, statErr) + } + + signPubHex = hex.EncodeToString(id.SignPub) + endpoint = frame.EndpointID(id.SignPub) + + if err := store.AddUser(signPubHex, handle, role); err != nil { + if errors.Is(err, membership.ErrUserExists) { + return "", "", fmt.Errorf("sign_pub %s already registered; revoke it first to replace", signPubHex) + } + return "", "", fmt.Errorf("register bot user: %w", err) + } + if err := client.WriteNewIdentity(out, id); err != nil { + return "", "", fmt.Errorf("write bot credentials to %q (user %s WAS registered — revoke it to retry): %w", out, signPubHex, err) + } + return signPubHex, endpoint, nil +} diff --git a/cmd/membershipd/bot_cli_test.go b/cmd/membershipd/bot_cli_test.go new file mode 100644 index 00000000..187fe5f3 --- /dev/null +++ b/cmd/membershipd/bot_cli_test.go @@ -0,0 +1,149 @@ +package main + +import ( + "encoding/hex" + "os" + "path/filepath" + "testing" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" + "github.com/enmanuel/unibus/pkg/membership" +) + +// openTestStore opens a fresh SQLite membership store in a temp dir. +func openTestStore(t *testing.T) membership.Store { + t.Helper() + store, err := membership.Open(filepath.Join(t.TempDir(), "unibus.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { store.Close() }) + return store +} + +// TestProvisionBotGolden is the happy path: provisioning a bot registers it in the +// allowlist with the right handle and role, AND writes a 0600 credentials file +// that LoadIdentity reconstructs into the same identity — so a worker/clientcheck +// binary pointed at the file connects as exactly this user with no extra step. +func TestProvisionBotGolden(t *testing.T) { + store := openTestStore(t) + out := filepath.Join(t.TempDir(), "notifier.id") + + signPubHex, endpoint, err := provisionBot(store, "notifier", membership.RoleMember, out) + if err != nil { + t.Fatalf("provisionBot: %v", err) + } + + // Registered in the allowlist with the right handle/role/status. + u, err := store.GetUser(signPubHex) + if err != nil { + t.Fatalf("get provisioned user: %v", err) + } + if u.Handle != "notifier" || u.Role != membership.RoleMember || u.Status != membership.StatusActive { + t.Fatalf("provisioned user row wrong: %+v", u) + } + + // And it shows up in user list (the `user list` surface). + users, err := store.ListUsers() + if err != nil { + t.Fatalf("list users: %v", err) + } + found := false + for _, x := range users { + if x.SignPub == signPubHex { + found = true + } + } + if !found { + t.Fatalf("provisioned bot missing from user list: %+v", users) + } + + // Credentials file exists, is 0600, and round-trips through LoadIdentity to the + // same signing key + endpoint (no-friction contract). + info, err := os.Stat(out) + if err != nil { + t.Fatalf("stat out file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("out file perms = %o, want 600", perm) + } + id, err := client.LoadIdentity(out) + if err != nil { + t.Fatalf("LoadIdentity(out): %v", err) + } + if got := hex.EncodeToString(id.SignPub); got != signPubHex { + t.Fatalf("loaded sign_pub %q != provisioned %q", got, signPubHex) + } + if got := frame.EndpointID(id.SignPub); got != endpoint { + t.Fatalf("loaded endpoint %q != reported %q", got, endpoint) + } +} + +// TestProvisionBotDefaultRole: an empty role defaults to member. +func TestProvisionBotDefaultRole(t *testing.T) { + store := openTestStore(t) + out := filepath.Join(t.TempDir(), "bot.id") + signPubHex, _, err := provisionBot(store, "defrole", "", out) + if err != nil { + t.Fatalf("provisionBot: %v", err) + } + u, err := store.GetUser(signPubHex) + if err != nil { + t.Fatalf("get user: %v", err) + } + if u.Role != membership.RoleMember { + t.Fatalf("empty role should default to member, got %q", u.Role) + } +} + +// TestProvisionBotSignPubAlreadyRegistered is the error path: provisioning an +// identity whose signing key is already in the allowlist fails with a clear error +// (not a panic) AND does not write a credentials file (no half-provisioned bot). +func TestProvisionBotSignPubAlreadyRegistered(t *testing.T) { + store := openTestStore(t) + + // Pre-register a key, then try to provision a bot with that SAME identity. + id, err := cs.GenerateIdentity() + if err != nil { + t.Fatalf("generate identity: %v", err) + } + signPubHex := hex.EncodeToString(id.SignPub) + if err := store.AddUser(signPubHex, "preexisting", membership.RoleMember); err != nil { + t.Fatalf("pre-register: %v", err) + } + + out := filepath.Join(t.TempDir(), "dup.id") + _, _, err = provisionBotWithIdentity(store, id, "dupbot", membership.RoleMember, out) + if err == nil { + t.Fatalf("provisioning an already-registered key should error") + } + if _, statErr := os.Stat(out); !os.IsNotExist(statErr) { + t.Fatalf("credentials file must NOT be written on a duplicate-key failure (stat err = %v)", statErr) + } +} + +// TestProvisionBotOutExists is the other error path: an existing --out file is +// refused BEFORE the store is mutated, so the run leaves no orphan user behind. +func TestProvisionBotOutExists(t *testing.T) { + store := openTestStore(t) + out := filepath.Join(t.TempDir(), "taken.id") + if err := os.WriteFile(out, []byte("preexisting credentials"), 0o600); err != nil { + t.Fatalf("seed out file: %v", err) + } + + _, _, err := provisionBot(store, "clobber", membership.RoleMember, out) + if err == nil { + t.Fatalf("provisioning over an existing out file should error") + } + // The store must be untouched: no user was registered. + users, err := store.ListUsers() + if err != nil { + t.Fatalf("list users: %v", err) + } + if len(users) != 0 { + t.Fatalf("no user should be registered when out exists, got %+v", users) + } +} diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index e28a7283..5670677f 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -47,6 +47,14 @@ func main() { runMigrateCLI(os.Args[2:]) return } + // `membershipd bot add ...` provisions a bus identity for an automated process + // in one command (mint identity + register + write 0600 credentials). It shares + // the same trusted-host model and store plumbing as the user CLI, so it is + // dispatched here before the server flag set parses os.Args. + if len(os.Args) > 1 && os.Args[1] == "bot" { + runBotCLI(os.Args[2:]) + return + } var ( bind = flag.String("bind", "127.0.0.1", "network interface to bind the HTTP API and the embedded NATS to; use 0.0.0.0 to accept LAN/remote peers") diff --git a/pkg/client/identity.go b/pkg/client/identity.go index 2a2aa537..742db717 100644 --- a/pkg/client/identity.go +++ b/pkg/client/identity.go @@ -75,6 +75,22 @@ func LoadOrCreateIdentity(path string) (cs.Identity, error) { return id, nil } +// WriteNewIdentity writes id to path in the canonical identity-file format read +// by LoadIdentity, but REFUSES to overwrite an existing file: provisioning a new +// identity must never silently clobber another process's private keys. The file +// is created 0600 (it holds private keys). It is the write half of one-command +// bot provisioning (`membershipd bot add --out `): the freshly minted +// identity it writes is exactly what LoadIdentity reconstructs, so a bot binary +// (worker/clientcheck) consumes the credentials with no extra conversion step. +func WriteNewIdentity(path string, id cs.Identity) error { + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("client: identity file %q already exists; refusing to overwrite", path) + } else if !os.IsNotExist(err) { + return fmt.Errorf("client: stat identity %q: %w", path, err) + } + return saveIdentity(path, id) +} + func saveIdentity(path string, id cs.Identity) error { if dir := filepath.Dir(path); dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil {