From e5e8e755531b508b37a1fcfdd3adda1027419083 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 25 Jun 2026 18:19:08 +0000 Subject: [PATCH 1/2] feat(files): provision-user CLI + anonymous public HTTP serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets external services (the TronBrowser extension store) host files on files.profullstack.com without the interactive `ssh join@` onboarding. - `agentbbs provision-user --name --pubkey ""`: registers a member from an SSH *public* key (account = handle + key fingerprint). Reuses SanitizeUsername (same rules as join@) + EnsureUser; Files/SFTP access is free for members, so the account can immediately `scp … files@host:/public/extensions//`. JSON output; refuses on key/ handle collision. New auth.FingerprintAuthorizedKey() parses an authorized_keys line to the same SHA256 fp as a live session key (tested). - setup.sh: the files. Caddy site now serves the shared /public area as unauthenticated, read-only static files (handle_path /public/*), so .crx/.zip download links work for anyone — mapping 1:1 to the SFTP path. Non-/public paths still hit the auth'd web file manager. - docs/files.md updated. Note: not compiled here — repo go.mod requires go 1.26 and this sandbox has 1.22.2; changes pass gofmt parse/format checks. Reuses existing store/auth APIs. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 4 ++ cmd/agentbbs/provision.go | 90 +++++++++++++++++++++++++++++++++ docs/files.md | 39 ++++++++++++++ internal/auth/auth.go | 13 +++++ internal/auth/provision_test.go | 50 ++++++++++++++++++ setup.sh | 14 +++++ 6 files changed, 210 insertions(+) create mode 100644 cmd/agentbbs/provision.go create mode 100644 internal/auth/provision_test.go diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index f5e2346..8a6a574 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -162,6 +162,10 @@ func main() { notifyCreds(st, os.Args[2:]) return } + if len(os.Args) > 1 && os.Args[1] == "provision-user" { + provisionUser(st, os.Args[2:]) + return + } if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" { qryptIssuerKeygen() return diff --git a/cmd/agentbbs/provision.go b/cmd/agentbbs/provision.go new file mode 100644 index 0000000..0f580b3 --- /dev/null +++ b/cmd/agentbbs/provision.go @@ -0,0 +1,90 @@ +package main + +// provision-user registers a member account from an SSH *public* key supplied +// out of band — the bridge that lets external services (e.g. the TronBrowser +// extension store, files.profullstack.com) onboard a publisher without the +// interactive `ssh join@` flow. An AgentBBS account is just (handle + key +// fingerprint), so this fingerprints the key and EnsureUser's it; Files/SFTP +// access is free for every member, so the account can immediately: +// +// scp dist.crx files@:/public/extensions// +// +// Mirrors the other operator subcommands (grant-pod, mint-token, …). Output is +// JSON on stdout so a caller can parse it; errors go to stderr with exit 1. +// +// agentbbs provision-user --name acme --pubkey "ssh-ed25519 AAAA… acme@dev" +// agentbbs provision-user --name acme --pubkey-file ./id_ed25519.pub + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/store" +) + +func provisionUser(st store.Store, args []string) { + fs := flag.NewFlagSet("provision-user", flag.ExitOnError) + name := fs.String("name", "", "member handle to create (a-z0-9-, 3-20, not reserved)") + pubkey := fs.String("pubkey", "", "SSH public key (authorized_keys line)") + pubkeyFile := fs.String("pubkey-file", "", "read the SSH public key from this file") + kind := fs.String("kind", string(auth.Member), "account kind: member | agent") + fs.Parse(args) + + // Normalize with the same rules the hub uses for self-service joins, so + // store-provisioned handles are indistinguishable from join@ ones. + handle, ok := auth.SanitizeUsername(*name) + if !ok { + fail("invalid --name: needs 3-20 chars of a-z, 0-9, dash and must not be reserved") + } + + keyText := strings.TrimSpace(*pubkey) + if keyText == "" && *pubkeyFile != "" { + b, err := os.ReadFile(*pubkeyFile) + if err != nil { + fail("read --pubkey-file: " + err.Error()) + } + keyText = strings.TrimSpace(string(b)) + } + if keyText == "" { + fail("provide --pubkey or --pubkey-file") + } + + fp, err := auth.FingerprintAuthorizedKey(keyText) + if err != nil { + fail("not a valid SSH public key: " + err.Error()) + } + + // If this key already belongs to someone, report that account rather than + // silently creating a second handle for the same key. + if existing, ok, err := st.UserByFingerprint(fp); err != nil { + fail("lookup by fingerprint: " + err.Error()) + } else if ok && existing.Name != handle { + fail(fmt.Sprintf("this key already belongs to member %q (fp %s)", existing.Name, fp)) + } + + u, err := st.EnsureUser(handle, *kind, fp) + if err != nil { + if errors.Is(err, store.ErrKeyMismatch) { + fail(fmt.Sprintf("handle %q is already registered with a different key", handle)) + } + fail("ensure user: " + err.Error()) + } + + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "ok": true, + "name": u.Name, + "kind": u.Kind, + "fingerprint": fp, + "store_id": u.ID, + }) +} + +func fail(msg string) { + fmt.Fprintln(os.Stderr, "provision-user: "+msg) + os.Exit(1) +} diff --git a/docs/files.md b/docs/files.md index 7c18629..c9d348b 100644 --- a/docs/files.md +++ b/docs/files.md @@ -88,6 +88,45 @@ content-blind. | `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) | | `AGENTBBS_DATA` | `./data` | storage lives under `/files/{users,public}` | +## Provisioning members from a public key (for external services) + +Normally members onboard interactively (`ssh join@`). External services that +want to grant a user file storage without that flow — e.g. the TronBrowser +extension store letting a publisher upload bundles — can register an account +directly from an SSH **public** key (an account is just *handle + key +fingerprint*): + +```bash +agentbbs provision-user --name acme --pubkey "ssh-ed25519 AAAA… acme@dev" +# or: --pubkey-file ./id_ed25519.pub +``` + +It normalizes the handle with the same rules as `join@` (`SanitizeUsername`), +fingerprints the key, and `EnsureUser`s the member; Files/SFTP access is then +available immediately (free for all members). Output is JSON (`{ok, name, +fingerprint, store_id}`); it refuses if the key already belongs to another +member or the handle is taken by a different key. The publisher can then: + +```bash +scp dist.crx files@files.profullstack.com:/public/extensions/acme/ +``` + +## Public files over HTTP (anonymous, read-only) + +The web file manager at `files.` requires a login even to download, which +is wrong for *shared* artifacts (a `.crx` download link must work for anyone). +So the `files.` Caddy site (generated by `setup.sh`) serves the shared +`/public` area as **unauthenticated, read-only** static files, mapping 1:1 to +the SFTP path: + +``` +scp x files@files.profullstack.com:/public/extensions/acme/x + -> https://files.profullstack.com/public/extensions/acme/x +``` + +Everything outside `/public/*` still falls through to the authenticated web +manager (private `/me` browsing). + ## Implementation - `internal/files` — a fully virtual Go SFTP server (`github.com/pkg/sftp` + diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f119bb9..544384e 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -204,3 +204,16 @@ func Fingerprint(key ssh.PublicKey) string { } return gossh.FingerprintSHA256(key) } + +// FingerprintAuthorizedKey parses an OpenSSH authorized_keys line (e.g. +// "ssh-ed25519 AAAA… comment") and returns its SHA256 fingerprint — the same +// value Fingerprint produces for a live session key. Used to provision a member +// from a public key supplied out of band (e.g. the extension store). Any +// trailing options/comment are ignored. +func FingerprintAuthorizedKey(authorizedKey string) (string, error) { + key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(strings.TrimSpace(authorizedKey))) + if err != nil { + return "", err + } + return gossh.FingerprintSHA256(key), nil +} diff --git a/internal/auth/provision_test.go b/internal/auth/provision_test.go new file mode 100644 index 0000000..777c40b --- /dev/null +++ b/internal/auth/provision_test.go @@ -0,0 +1,50 @@ +package auth + +import ( + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + + gossh "golang.org/x/crypto/ssh" +) + +func TestFingerprintAuthorizedKey(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + sshPub, err := gossh.NewPublicKey(pub) + if err != nil { + t.Fatal(err) + } + authLine := string(gossh.MarshalAuthorizedKey(sshPub)) // "ssh-ed25519 AAAA…\n" + want := gossh.FingerprintSHA256(sshPub) + + // Bare line. + got, err := FingerprintAuthorizedKey(authLine) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Fatalf("fingerprint = %q, want %q", got, want) + } + + // With a trailing comment + surrounding whitespace. + got2, err := FingerprintAuthorizedKey(" " + strings.TrimRight(authLine, "\n") + " acme@dev ") + if err != nil { + t.Fatalf("unexpected error with comment: %v", err) + } + if got2 != want { + t.Fatalf("fingerprint with comment = %q, want %q", got2, want) + } +} + +func TestFingerprintAuthorizedKeyRejectsGarbage(t *testing.T) { + if _, err := FingerprintAuthorizedKey("not a key"); err == nil { + t.Fatal("expected error for non-key input") + } + if _, err := FingerprintAuthorizedKey(""); err == nil { + t.Fatal("expected error for empty input") + } +} diff --git a/setup.sh b/setup.sh index 2f969e7..81c1c5d 100755 --- a/setup.sh +++ b/setup.sh @@ -699,6 +699,20 @@ fi FILES_SITE=" ${FILES_DOMAIN} { encode zstd gzip + + # Public file area — unauthenticated, read-only HTTP for the shared /public + # directory, so download links (e.g. extension .crx/.zip) work for everyone. + # Maps 1:1 to the SFTP path: a member who runs + # scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/ + # gets the URL https://${FILES_DOMAIN}/public/extensions/acme/dist.crx . + # Everything else falls through to the auth'd web file manager below. + handle_path /public/* { + root * ${DATA_DIR}/files/public + header Cache-Control \"public, max-age=300\" + file_server + } + + # Member web file manager (webmail-password login; /me + /public browsing). reverse_proxy http://${FILES_WEB_ADDR} } " From d1615ac81726b1842b229ed806bd90a2505b35b1 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 25 Jun 2026 18:36:23 +0000 Subject: [PATCH 2/2] fix(vet): redundant newline in wish.Println premium-flow messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go test ./...` / `go vet ./...` fail on `wish.Println(… "…\n")` — Println already appends a newline. Pre-existing on main (its CI is red for the same two lines); surfaced here. Switched both to `wish.Print` with an explicit trailing "\n\n" so output bytes are unchanged and vet is satisfied. Co-Authored-By: Claude Opus 4.8 --- cmd/agentbbs/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 8a6a574..de57789 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -968,7 +968,7 @@ func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) { wish.Print(s, "\n Become a Founding member now? Type \"yes\" for a payment address [no]: ") line, err := readLine(s, in) if err != nil || !isYes(line) { - wish.Println(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n") + wish.Print(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n\n") return } @@ -978,7 +978,7 @@ func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) { if err != nil { log.Error("create premium charge", "err", err) } - wish.Println(s, "\n Payment is temporarily unavailable — please try again shortly.\n") + wish.Print(s, "\n Payment is temporarily unavailable — please try again shortly.\n\n") return } // Remember the payment id so a later connect can confirm settlement.