files: provision-user CLI + anonymous public HTTP serving (#58)

* feat(files): provision-user CLI + anonymous public HTTP serving

Lets external services (the TronBrowser extension store) host files on
files.profullstack.com without the interactive `ssh join@` onboarding.

- `agentbbs provision-user --name <h> --pubkey "<ssh key>"`: 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/<slug>/`. 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.<host> 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 <noreply@anthropic.com>

* fix(vet): redundant newline in wish.Println premium-flow messages

`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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-25 11:37:45 -07:00 committed by GitHub
parent fb7722eacc
commit eb8ef546cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 212 additions and 2 deletions

View file

@ -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
@ -964,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
}
@ -974,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.

90
cmd/agentbbs/provision.go Normal file
View file

@ -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@<host>:/public/extensions/<slug>/
//
// 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)
}