Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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.
Expand Down
90 changes: 90 additions & 0 deletions cmd/agentbbs/provision.go
Original file line number Diff line number Diff line change
@@ -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)
}
39 changes: 39 additions & 0 deletions docs/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,45 @@ content-blind.
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/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.<host>` requires a login even to download, which
is wrong for *shared* artifacts (a `.crx` download link must work for anyone).
So the `files.<host>` 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` +
Expand Down
13 changes: 13 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
50 changes: 50 additions & 0 deletions internal/auth/provision_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
14 changes: 14 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
"
Expand Down
Loading