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:
Anthony Ettinger 2026-06-25 18:19:08 +00:00
parent fb7722eacc
commit e5e8e75553
6 changed files with 210 additions and 0 deletions

View file

@ -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
}

View 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")
}
}