feat(membershipd): one-command bot provisioning (bot add)

Add `membershipd bot add --handle <name> --out <path> [--role] [--store]` to
provision a bus identity for an automated process in a single step: mint a fresh
Ed25519+X25519 identity (cs.GenerateIdentity, the same derivation worker/chat
use), register its signing key in the allowlist, and write the credentials to a
0600 file. The file is the canonical identity format read by client.LoadIdentity,
so a worker/clientcheck binary pointed at --out connects as the new user with no
extra conversion. Shares the sqlite/kv store plumbing with `user add`.

New exported pkg/client.WriteNewIdentity writes an identity in that format but
refuses to overwrite an existing file (never silently clobber private keys).

provisionBot ordering guarantees no half-provisioned bot: refuse an existing
--out before touching the store, register (an already-registered key is a clear
error, not a panic), then write credentials. Tests cover the golden path
(register + 0600 file + LoadIdentity round-trip), default role, the
already-registered error path (no file written), and the out-exists error path
(no orphan user).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:30:17 +02:00
parent 2ba40701b2
commit 669bad52af
4 changed files with 332 additions and 0 deletions
+159
View File
@@ -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 <name> human-readable name for the bot (shown in the directory)
--out <path> where to write the bot credentials (refused if it exists)
optional flags:
--role <role> admin or member (default member)
--store <kind> sqlite (local DB, default) | kv (the live cluster's allowlist)
--db <path> SQLite database path (--store sqlite; default ./local_files/unibus.db)
--store kv flags (defaults assume an on-node invocation):
--nats-url <url> cluster NATS (default nats://127.0.0.1:4250)
--internal-id-file <path> persisted internal service identity (default /opt/unibus/secrets/internal.id)
--ca <path> CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt)
--kv-replicas <n> 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 <path>) 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
}
+149
View File
@@ -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)
}
}
+8
View File
@@ -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")
+16
View File
@@ -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 <path>`): 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 {