agentbbs/internal/auth/auth.go
Anthony Ettinger 362b47fdde
Feat/members messaging (#29)
* fix(deploy): build Go binaries on the runner, ship them, SKIP_BUILD on box

The deploy SSHed into the ~458MB droplet and ran `go build` there. The Go
linker's peak memory OOM-killed the build — and with it the sshd serving the
deploy session — surfacing as "Connection closed by remote host" (exit 255).
It was flaky because it tracked momentary memory pressure from the co-resident
ergo/forgejo/tor/podman/agentbbs processes (run #25 passed, #26 failed on
near-identical code).

Build both binaries on the 16GB GitHub runner instead (pure-Go, modernc
sqlite, so CGO_ENABLED=0 static cross-build), scp them to the droplet, and run
setup.sh with SKIP_BUILD=1 so the box never compiles. Arch is detected from
the droplet so amd64/arm64 both work. setup.sh now also skips the Go toolchain
download when SKIP_BUILD=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(members): member directory + store-and-forward messaging

A members-only hub plugin (the BBS "who") plus user-to-user messaging:

- internal/store: messages table + SendMessage/Inbox/UnreadCount/MarkRead, and
  OnlineUsers (open sessions) for presence. MarkRead is recipient-scoped so a
  member can only clear their own mail.
- plugins/members: directory with online dots + last-seen, a finger-style
  profile view, a minimal compose box, and an inbox that marks read on open.
- ssh msg@host <user> [text]: scriptable CLI to leave a note (body from args or
  stdin), mirroring the existing finger route; "msg"/"message" are reserved.
- hub: "N unread" badge on login (hubMOTD). plugin.Context gains Host for member
  homepage URLs.

Extends the existing finger@ behavior (ssh <name>@host) rather than replacing it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 07:48:07 -07:00

196 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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.
var AdminNames = map[string]bool{"admin": true, "sysop": 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}
// 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)] }
// 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] ||
MsgNames[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-], 320 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)
}