Skip to content

Commit eb8ef54

Browse files
ralyodioclaude
andauthored
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>
1 parent fb7722e commit eb8ef54

6 files changed

Lines changed: 212 additions & 2 deletions

File tree

cmd/agentbbs/main.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ func main() {
162162
notifyCreds(st, os.Args[2:])
163163
return
164164
}
165+
if len(os.Args) > 1 && os.Args[1] == "provision-user" {
166+
provisionUser(st, os.Args[2:])
167+
return
168+
}
165169
if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" {
166170
qryptIssuerKeygen()
167171
return
@@ -964,7 +968,7 @@ func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) {
964968
wish.Print(s, "\n Become a Founding member now? Type \"yes\" for a payment address [no]: ")
965969
line, err := readLine(s, in)
966970
if err != nil || !isYes(line) {
967-
wish.Println(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n")
971+
wish.Print(s, "\n No problem — you're a free member. Want it later? Re-run: ssh join@"+a.host+"\n\n")
968972
return
969973
}
970974

@@ -974,7 +978,7 @@ func (a *app) offerPremium(s ssh.Session, in *bufio.Reader, u *store.User) {
974978
if err != nil {
975979
log.Error("create premium charge", "err", err)
976980
}
977-
wish.Println(s, "\n Payment is temporarily unavailable — please try again shortly.\n")
981+
wish.Print(s, "\n Payment is temporarily unavailable — please try again shortly.\n\n")
978982
return
979983
}
980984
// Remember the payment id so a later connect can confirm settlement.

cmd/agentbbs/provision.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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+
}

docs/files.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,45 @@ content-blind.
8888
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
8989
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` |
9090

91+
## Provisioning members from a public key (for external services)
92+
93+
Normally members onboard interactively (`ssh join@`). External services that
94+
want to grant a user file storage without that flow — e.g. the TronBrowser
95+
extension store letting a publisher upload bundles — can register an account
96+
directly from an SSH **public** key (an account is just *handle + key
97+
fingerprint*):
98+
99+
```bash
100+
agentbbs provision-user --name acme --pubkey "ssh-ed25519 AAAA… acme@dev"
101+
# or: --pubkey-file ./id_ed25519.pub
102+
```
103+
104+
It normalizes the handle with the same rules as `join@` (`SanitizeUsername`),
105+
fingerprints the key, and `EnsureUser`s the member; Files/SFTP access is then
106+
available immediately (free for all members). Output is JSON (`{ok, name,
107+
fingerprint, store_id}`); it refuses if the key already belongs to another
108+
member or the handle is taken by a different key. The publisher can then:
109+
110+
```bash
111+
scp dist.crx files@files.profullstack.com:/public/extensions/acme/
112+
```
113+
114+
## Public files over HTTP (anonymous, read-only)
115+
116+
The web file manager at `files.<host>` requires a login even to download, which
117+
is wrong for *shared* artifacts (a `.crx` download link must work for anyone).
118+
So the `files.<host>` Caddy site (generated by `setup.sh`) serves the shared
119+
`/public` area as **unauthenticated, read-only** static files, mapping 1:1 to
120+
the SFTP path:
121+
122+
```
123+
scp x files@files.profullstack.com:/public/extensions/acme/x
124+
-> https://files.profullstack.com/public/extensions/acme/x
125+
```
126+
127+
Everything outside `/public/*` still falls through to the authenticated web
128+
manager (private `/me` browsing).
129+
91130
## Implementation
92131

93132
- `internal/files` — a fully virtual Go SFTP server (`github.com/pkg/sftp` +

internal/auth/auth.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,16 @@ func Fingerprint(key ssh.PublicKey) string {
204204
}
205205
return gossh.FingerprintSHA256(key)
206206
}
207+
208+
// FingerprintAuthorizedKey parses an OpenSSH authorized_keys line (e.g.
209+
// "ssh-ed25519 AAAA… comment") and returns its SHA256 fingerprint — the same
210+
// value Fingerprint produces for a live session key. Used to provision a member
211+
// from a public key supplied out of band (e.g. the extension store). Any
212+
// trailing options/comment are ignored.
213+
func FingerprintAuthorizedKey(authorizedKey string) (string, error) {
214+
key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(strings.TrimSpace(authorizedKey)))
215+
if err != nil {
216+
return "", err
217+
}
218+
return gossh.FingerprintSHA256(key), nil
219+
}

internal/auth/provision_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package auth
2+
3+
import (
4+
"crypto/ed25519"
5+
"crypto/rand"
6+
"strings"
7+
"testing"
8+
9+
gossh "golang.org/x/crypto/ssh"
10+
)
11+
12+
func TestFingerprintAuthorizedKey(t *testing.T) {
13+
pub, _, err := ed25519.GenerateKey(rand.Reader)
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
sshPub, err := gossh.NewPublicKey(pub)
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
authLine := string(gossh.MarshalAuthorizedKey(sshPub)) // "ssh-ed25519 AAAA…\n"
22+
want := gossh.FingerprintSHA256(sshPub)
23+
24+
// Bare line.
25+
got, err := FingerprintAuthorizedKey(authLine)
26+
if err != nil {
27+
t.Fatalf("unexpected error: %v", err)
28+
}
29+
if got != want {
30+
t.Fatalf("fingerprint = %q, want %q", got, want)
31+
}
32+
33+
// With a trailing comment + surrounding whitespace.
34+
got2, err := FingerprintAuthorizedKey(" " + strings.TrimRight(authLine, "\n") + " acme@dev ")
35+
if err != nil {
36+
t.Fatalf("unexpected error with comment: %v", err)
37+
}
38+
if got2 != want {
39+
t.Fatalf("fingerprint with comment = %q, want %q", got2, want)
40+
}
41+
}
42+
43+
func TestFingerprintAuthorizedKeyRejectsGarbage(t *testing.T) {
44+
if _, err := FingerprintAuthorizedKey("not a key"); err == nil {
45+
t.Fatal("expected error for non-key input")
46+
}
47+
if _, err := FingerprintAuthorizedKey(""); err == nil {
48+
t.Fatal("expected error for empty input")
49+
}
50+
}

setup.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,20 @@ fi
699699
FILES_SITE="
700700
${FILES_DOMAIN} {
701701
encode zstd gzip
702+
703+
# Public file area — unauthenticated, read-only HTTP for the shared /public
704+
# directory, so download links (e.g. extension .crx/.zip) work for everyone.
705+
# Maps 1:1 to the SFTP path: a member who runs
706+
# scp dist.crx files@${FILES_DOMAIN}:/public/extensions/acme/
707+
# gets the URL https://${FILES_DOMAIN}/public/extensions/acme/dist.crx .
708+
# Everything else falls through to the auth'd web file manager below.
709+
handle_path /public/* {
710+
root * ${DATA_DIR}/files/public
711+
header Cache-Control \"public, max-age=300\"
712+
file_server
713+
}
714+
715+
# Member web file manager (webmail-password login; /me + /public browsing).
702716
reverse_proxy http://${FILES_WEB_ADDR}
703717
}
704718
"

0 commit comments

Comments
 (0)