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 }