mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
This commit is contained in:
parent
fb7722eacc
commit
e5e8e75553
6 changed files with 210 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
90
cmd/agentbbs/provision.go
Normal file
90
cmd/agentbbs/provision.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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` +
|
||||
|
|
|
|||
|
|
@ -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
internal/auth/provision_test.go
Normal file
50
internal/auth/provision_test.go
Normal file
|
|
@ -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
setup.sh
14
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}
|
||||
}
|
||||
"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue