mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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>
219 lines
9.1 KiB
Go
219 lines
9.1 KiB
Go
// Package auth resolves SSH connections into AgentBBS identities (PRD §4.4).
|
||
package auth
|
||
|
||
import (
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/ssh"
|
||
gossh "golang.org/x/crypto/ssh"
|
||
)
|
||
|
||
// Kind classifies an identity.
|
||
type Kind string
|
||
|
||
const (
|
||
Guest Kind = "guest"
|
||
Member Kind = "member"
|
||
Agent Kind = "agent"
|
||
)
|
||
|
||
// User is the resolved identity for one session.
|
||
type User struct {
|
||
Name string
|
||
Kind Kind
|
||
PubKeyFP string // SHA256 fingerprint, empty for guests without a key
|
||
StoreID int64 // 0 for guests
|
||
}
|
||
|
||
// GuestNames are usernames that always map to an anonymous guest hub session.
|
||
var GuestNames = map[string]bool{"bbs": true, "play": true, "guest": true}
|
||
|
||
// PodNames are usernames that route to a personal pod instead of the hub.
|
||
// Pod access requires an active paid membership (PRD pods addendum).
|
||
var PodNames = map[string]bool{"pod": true}
|
||
|
||
// JoinNames are usernames that trigger the onboarding flow: register the
|
||
// visitor's public key, print instructions, and disconnect.
|
||
var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
|
||
|
||
// DomainNames are usernames that route to the custom-domain self-service flow:
|
||
// list/add/remove the domains pointed at a member's homepage.
|
||
var DomainNames = map[string]bool{"domain": true, "domains": true}
|
||
|
||
// AdminNames are usernames that route to the privileged admin console (PRD §6).
|
||
// The route only opens for accounts whose name is in the operator allowlist
|
||
// (see IsAdmin); the name itself confers nothing — so "root" is just a familiar
|
||
// alias here, not a backdoor.
|
||
var AdminNames = map[string]bool{"admin": true, "sysop": true, "root": true}
|
||
|
||
// TorURLNames route to the one-shot "fetch a URL over Tor" command (premium).
|
||
var TorURLNames = map[string]bool{"tor-url": true}
|
||
|
||
// TorIRCNames route to an interactive IRC-over-Tor client in the member's pod.
|
||
var TorIRCNames = map[string]bool{"tor-irc": true}
|
||
|
||
// TorNames route to the generic "run a command over Tor" passthrough in the
|
||
// member's pod (premium). Checked after the more specific tor-* routes.
|
||
var TorNames = map[string]bool{"tor": true}
|
||
|
||
// IRCNames is kept only to reserve "irc" as an account/subdomain name: the BBS
|
||
// hosts its own IRC network at irc.<domain> but there is no in-BBS irc@ route —
|
||
// members connect with an external client. (Distinct from tor-irc@.)
|
||
var IRCNames = map[string]bool{"irc": true}
|
||
|
||
// NewsNames route a member into the BBS's own (members-only) Usenet/NNTP server
|
||
// via an in-process newsreader. Free for any registered member, like irc@.
|
||
var NewsNames = map[string]bool{"news": true}
|
||
|
||
// MailNames route a Founding Lifetime (paid) member into the AgentMail client —
|
||
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
|
||
var MailNames = map[string]bool{"mail": true}
|
||
|
||
// FilesAdminNames route an operator into the SFTP server management TUI. Members
|
||
// transfer files via the "sftp" subsystem (sftp files@<host>); this interactive
|
||
// route is the operator console and is gated by the admin allowlist.
|
||
var FilesAdminNames = map[string]bool{"sftp": true, "sftpadmin": true, "filesadmin": true}
|
||
|
||
// GameNames are usernames that route to AgentGames: the line-delimited-JSON
|
||
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
|
||
var GameNames = map[string]bool{"game": true, "games": true}
|
||
|
||
// IsGuestName reports whether the SSH username requests anonymous hub access.
|
||
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
|
||
|
||
// IsPodName reports whether the SSH username requests the pod route.
|
||
func IsPodName(u string) bool { return PodNames[strings.ToLower(u)] }
|
||
|
||
// IsJoinName reports whether the SSH username requests onboarding.
|
||
func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
|
||
|
||
// IsDomainName reports whether the SSH username requests the custom-domain flow.
|
||
func IsDomainName(u string) bool { return DomainNames[strings.ToLower(u)] }
|
||
|
||
// IsAdminName reports whether the SSH username requests the admin console.
|
||
func IsAdminName(u string) bool { return AdminNames[strings.ToLower(u)] }
|
||
|
||
// IsTorURLName reports whether the SSH username requests the tor-url fetch.
|
||
func IsTorURLName(u string) bool { return TorURLNames[strings.ToLower(u)] }
|
||
|
||
// IsTorIRCName reports whether the SSH username requests the tor-irc client.
|
||
func IsTorIRCName(u string) bool { return TorIRCNames[strings.ToLower(u)] }
|
||
|
||
// IsTorName reports whether the SSH username requests the generic tor passthrough.
|
||
func IsTorName(u string) bool { return TorNames[strings.ToLower(u)] }
|
||
|
||
// IsNewsName reports whether the SSH username requests the in-BBS newsreader.
|
||
func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
|
||
|
||
// IsMailName reports whether the SSH username requests the AgentMail client.
|
||
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
|
||
|
||
// IsFilesAdminName reports whether the SSH username requests the SFTP server
|
||
// management TUI (operator-gated).
|
||
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
|
||
|
||
// MsgNames route a member-to-member message: `ssh msg@host <user>` leaves a
|
||
// note in the recipient's BBS inbox (store-and-forward, see the Members plugin).
|
||
var MsgNames = map[string]bool{"msg": true, "message": true}
|
||
|
||
// IsMsgName reports whether the SSH username requests the messaging route.
|
||
func IsMsgName(u string) bool { return MsgNames[strings.ToLower(u)] }
|
||
|
||
// systemReserved are names that don't drive an SSH route but would still
|
||
// collide with a per-user subdomain (<name>.<host>), the agent route, or common
|
||
// infra hostnames — so members may not claim them as account names.
|
||
var systemReserved = map[string]bool{
|
||
"agent": true, "video": true, "www": true, "api": true, "mail": true,
|
||
"smtp": true, "imap": true, "ftp": true, "ns": true, "ns1": true, "ns2": true,
|
||
"cdn": true, "static": true, "assets": true, "root": true, "abuse": true,
|
||
"postmaster": true, "webmaster": true, "support": true, "help": true,
|
||
"admin": true, "sysop": true, "bbs": true, "guest": true, "pod": true,
|
||
}
|
||
|
||
// IsReservedName reports whether name is claimed by a route or infra label and
|
||
// therefore cannot be used as a member's account name.
|
||
func IsReservedName(name string) bool {
|
||
n := strings.ToLower(name)
|
||
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
|
||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
|
||
MailNames[n] || FilesAdminNames[n] || MsgNames[n] || GameNames[n] || systemReserved[n] {
|
||
return true
|
||
}
|
||
return strings.HasPrefix(n, "video-") // video-<code> call routes
|
||
}
|
||
|
||
// SanitizeUsername normalizes a requested account name to the charset the hub
|
||
// and per-user subdomains allow: lowercased [a-z0-9-], 3–20 chars, with '_' and
|
||
// spaces folded to '-', no doubled, leading, or trailing dashes. It returns the
|
||
// cleaned name and whether it is usable (right length and not reserved).
|
||
func SanitizeUsername(raw string) (string, bool) {
|
||
var b strings.Builder
|
||
lastDash := false
|
||
for _, r := range strings.ToLower(strings.TrimSpace(raw)) {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||
b.WriteRune(r)
|
||
lastDash = false
|
||
case r == '-' || r == '_' || r == ' ':
|
||
if b.Len() > 0 && !lastDash {
|
||
b.WriteByte('-')
|
||
lastDash = true
|
||
}
|
||
}
|
||
}
|
||
name := strings.Trim(b.String(), "-")
|
||
if len(name) < 3 || len(name) > 20 || IsReservedName(name) {
|
||
return name, false
|
||
}
|
||
return name, true
|
||
}
|
||
|
||
// IsGameName reports whether the SSH username requests the AgentGames protocol.
|
||
func IsGameName(u string) bool { return GameNames[strings.ToLower(u)] }
|
||
|
||
// Admins returns the operator-configured admin allowlist: the lowercased,
|
||
// comma/space-separated account names in $AGENTBBS_ADMINS. Admin status can
|
||
// only be granted by the operator (via env), never self-assigned in-band.
|
||
func Admins() map[string]bool {
|
||
out := map[string]bool{}
|
||
for _, f := range strings.FieldsFunc(os.Getenv("AGENTBBS_ADMINS"), func(r rune) bool {
|
||
return r == ',' || r == ' ' || r == '\t' || r == '\n'
|
||
}) {
|
||
out[strings.ToLower(f)] = true
|
||
}
|
||
return out
|
||
}
|
||
|
||
// IsAdmin reports whether the account name is in the operator allowlist.
|
||
func IsAdmin(name string) bool { return Admins()[strings.ToLower(name)] }
|
||
|
||
// KindFor infers the identity kind from a (non-guest) username.
|
||
// Usernames prefixed "agent-" are automated clients (PRD §3).
|
||
func KindFor(username string) Kind {
|
||
if strings.HasPrefix(strings.ToLower(username), "agent-") {
|
||
return Agent
|
||
}
|
||
return Member
|
||
}
|
||
|
||
// Fingerprint returns the SHA256 fingerprint for a session public key, or "".
|
||
func Fingerprint(key ssh.PublicKey) string {
|
||
if key == nil {
|
||
return ""
|
||
}
|
||
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
|
||
}
|