package main import ( "encoding/hex" "flag" "fmt" "os" "strings" "text/tabwriter" "github.com/enmanuel/unibus/pkg/membership" ) // runUserCLI implements `membershipd user ...`, the local // administration surface for the bus user allowlist. It opens the SQLite store // directly (no network, no auth): it is meant to run on the bus host, where // shell access already implies full control. This is the seam that seeds the // first admin, breaking the chicken-egg of "you need an admin to add an admin". // // The function never returns: it exits the process with a non-zero status on // error so it composes cleanly in shell scripts and systemd ExecStartPre hooks. func runUserCLI(args []string) { if len(args) == 0 { userUsage() os.Exit(2) } sub, rest := args[0], args[1:] switch sub { case "add": userAdd(rest) case "list": userList(rest) case "revoke": userRevoke(rest) case "-h", "--help", "help": userUsage() os.Exit(0) default: fmt.Fprintf(os.Stderr, "membershipd user: unknown subcommand %q\n\n", sub) userUsage() os.Exit(2) } } func userUsage() { fmt.Fprint(os.Stderr, `usage: membershipd user [flags] commands: add Register a bus user from their Ed25519 signing public key list List all registered users revoke Revoke a user (denies access on both planes immediately) examples: membershipd user add --handle alice --sign-pub <64-hex> --role admin membershipd user list membershipd user revoke <64-hex> common flags: --db SQLite database path (default ./local_files/unibus.db) `) } const defaultDBPath = "./local_files/unibus.db" // openStore opens the membership store at path, exiting on failure. Migrations // (including 002_users.sql) are applied by membership.Open, so a fresh database // gets the users table on first use of the CLI. func openStore(path string) membership.Store { store, err := membership.Open(path) if err != nil { fmt.Fprintf(os.Stderr, "membershipd user: open store %q: %v\n", path, err) os.Exit(1) } return store } // validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in // hex (64 hex chars). Catching this here turns a silent "authorized nobody" into // an explicit error at seed time. func validateSignPubHex(signPub string) error { b, err := hex.DecodeString(signPub) if err != nil { return fmt.Errorf("sign-pub is not valid hex: %w", err) } if len(b) != 32 { return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b)) } return nil } func userAdd(args []string) { fs := flag.NewFlagSet("user add", flag.ExitOnError) handle := fs.String("handle", "", "human-readable user name (required)") signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)") role := fs.String("role", membership.RoleMember, "role: admin or member") dbPath := fs.String("db", defaultDBPath, "SQLite database path") _ = fs.Parse(args) if *handle == "" || *signPub == "" { fmt.Fprintln(os.Stderr, "membershipd user add: --handle and --sign-pub are required") os.Exit(2) } if err := validateSignPubHex(*signPub); err != nil { fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err) os.Exit(2) } store := openStore(*dbPath) defer store.Close() if err := store.AddUser(*signPub, *handle, *role); err != nil { fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err) os.Exit(1) } fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role) } func userList(args []string) { fs := flag.NewFlagSet("user list", flag.ExitOnError) dbPath := fs.String("db", defaultDBPath, "SQLite database path") _ = fs.Parse(args) store := openStore(*dbPath) defer store.Close() users, err := store.ListUsers() if err != nil { fmt.Fprintf(os.Stderr, "membershipd user list: %v\n", err) os.Exit(1) } if len(users) == 0 { fmt.Println("(no users)") return } w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) fmt.Fprintln(w, "HANDLE\tROLE\tSTATUS\tSIGN_PUB\tCREATED") for _, u := range users { fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", u.Handle, u.Role, u.Status, u.SignPub, u.CreatedAt) } _ = w.Flush() } func userRevoke(args []string) { fs := flag.NewFlagSet("user revoke", flag.ExitOnError) dbPath := fs.String("db", defaultDBPath, "SQLite database path") // Go's flag package stops at the first non-flag argument, so `revoke // --db path` would otherwise leave --db unparsed. Pull a leading positional // (the sign-pub) off the front before parsing so both `revoke --db p` // and `revoke --db p ` work for the operator. var signPub string if len(args) > 0 && !strings.HasPrefix(args[0], "-") { signPub, args = args[0], args[1:] } _ = fs.Parse(args) if signPub == "" { if rest := fs.Args(); len(rest) == 1 { signPub = rest[0] } } if signPub == "" { fmt.Fprintln(os.Stderr, "membershipd user revoke: exactly one argument required") os.Exit(2) } if err := validateSignPubHex(signPub); err != nil { fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err) os.Exit(2) } store := openStore(*dbPath) defer store.Close() if err := store.RevokeUser(signPub); err != nil { fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err) os.Exit(1) } fmt.Printf("revoked user %s\n", signPub) }