669bad52af
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>
160 lines
6.1 KiB
Go
160 lines
6.1 KiB
Go
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
|
|
}
|