mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Add three premium-gated SSH routes:
ssh tor-url@host <url> one-shot HTTP(S) GET over Tor (host-side,
curl via SOCKS, 30s/2MB caps, http/https only)
ssh -t tor-irc@host <server> interactive IRC over Tor in the member's pod
ssh -t tor@host <command...> run any command over Tor (torsocks) in the pod
tor-url runs host-side and constrained; tor/tor-irc run inside the member's
isolated pod (new pods.Exec) so arbitrary/interactive commands are sandboxed,
never on the host. internal/tor wraps curl/torsocks/irssi. All gated by
ensurePremium; names reserved. setup.sh installs + enables tor (SOCKS
127.0.0.1:9050) and torsocks.
Note: tor-url is host-side and self-contained. tor/tor-irc still need the pod
image to carry torsocks+irssi and reach the Tor SOCKS — follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
6.5 KiB
Go
169 lines
6.5 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.
|
||
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}
|
||
|
||
// 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)] }
|
||
|
||
// 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] || 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)
|
||
}
|