|
| 1 | +package main |
| 2 | + |
| 3 | +// provision-user registers a member account from an SSH *public* key supplied |
| 4 | +// out of band — the bridge that lets external services (e.g. the TronBrowser |
| 5 | +// extension store, files.profullstack.com) onboard a publisher without the |
| 6 | +// interactive `ssh join@` flow. An AgentBBS account is just (handle + key |
| 7 | +// fingerprint), so this fingerprints the key and EnsureUser's it; Files/SFTP |
| 8 | +// access is free for every member, so the account can immediately: |
| 9 | +// |
| 10 | +// scp dist.crx files@<host>:/public/extensions/<slug>/ |
| 11 | +// |
| 12 | +// Mirrors the other operator subcommands (grant-pod, mint-token, …). Output is |
| 13 | +// JSON on stdout so a caller can parse it; errors go to stderr with exit 1. |
| 14 | +// |
| 15 | +// agentbbs provision-user --name acme --pubkey "ssh-ed25519 AAAA… acme@dev" |
| 16 | +// agentbbs provision-user --name acme --pubkey-file ./id_ed25519.pub |
| 17 | + |
| 18 | +import ( |
| 19 | + "encoding/json" |
| 20 | + "errors" |
| 21 | + "flag" |
| 22 | + "fmt" |
| 23 | + "os" |
| 24 | + "strings" |
| 25 | + |
| 26 | + "github.com/profullstack/agentbbs/internal/auth" |
| 27 | + "github.com/profullstack/agentbbs/internal/store" |
| 28 | +) |
| 29 | + |
| 30 | +func provisionUser(st store.Store, args []string) { |
| 31 | + fs := flag.NewFlagSet("provision-user", flag.ExitOnError) |
| 32 | + name := fs.String("name", "", "member handle to create (a-z0-9-, 3-20, not reserved)") |
| 33 | + pubkey := fs.String("pubkey", "", "SSH public key (authorized_keys line)") |
| 34 | + pubkeyFile := fs.String("pubkey-file", "", "read the SSH public key from this file") |
| 35 | + kind := fs.String("kind", string(auth.Member), "account kind: member | agent") |
| 36 | + fs.Parse(args) |
| 37 | + |
| 38 | + // Normalize with the same rules the hub uses for self-service joins, so |
| 39 | + // store-provisioned handles are indistinguishable from join@ ones. |
| 40 | + handle, ok := auth.SanitizeUsername(*name) |
| 41 | + if !ok { |
| 42 | + fail("invalid --name: needs 3-20 chars of a-z, 0-9, dash and must not be reserved") |
| 43 | + } |
| 44 | + |
| 45 | + keyText := strings.TrimSpace(*pubkey) |
| 46 | + if keyText == "" && *pubkeyFile != "" { |
| 47 | + b, err := os.ReadFile(*pubkeyFile) |
| 48 | + if err != nil { |
| 49 | + fail("read --pubkey-file: " + err.Error()) |
| 50 | + } |
| 51 | + keyText = strings.TrimSpace(string(b)) |
| 52 | + } |
| 53 | + if keyText == "" { |
| 54 | + fail("provide --pubkey or --pubkey-file") |
| 55 | + } |
| 56 | + |
| 57 | + fp, err := auth.FingerprintAuthorizedKey(keyText) |
| 58 | + if err != nil { |
| 59 | + fail("not a valid SSH public key: " + err.Error()) |
| 60 | + } |
| 61 | + |
| 62 | + // If this key already belongs to someone, report that account rather than |
| 63 | + // silently creating a second handle for the same key. |
| 64 | + if existing, ok, err := st.UserByFingerprint(fp); err != nil { |
| 65 | + fail("lookup by fingerprint: " + err.Error()) |
| 66 | + } else if ok && existing.Name != handle { |
| 67 | + fail(fmt.Sprintf("this key already belongs to member %q (fp %s)", existing.Name, fp)) |
| 68 | + } |
| 69 | + |
| 70 | + u, err := st.EnsureUser(handle, *kind, fp) |
| 71 | + if err != nil { |
| 72 | + if errors.Is(err, store.ErrKeyMismatch) { |
| 73 | + fail(fmt.Sprintf("handle %q is already registered with a different key", handle)) |
| 74 | + } |
| 75 | + fail("ensure user: " + err.Error()) |
| 76 | + } |
| 77 | + |
| 78 | + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ |
| 79 | + "ok": true, |
| 80 | + "name": u.Name, |
| 81 | + "kind": u.Kind, |
| 82 | + "fingerprint": fp, |
| 83 | + "store_id": u.ID, |
| 84 | + }) |
| 85 | +} |
| 86 | + |
| 87 | +func fail(msg string) { |
| 88 | + fmt.Fprintln(os.Stderr, "provision-user: "+msg) |
| 89 | + os.Exit(1) |
| 90 | +} |
0 commit comments