M2: admin console over ssh admin@ (users, sessions, moderation, plugins) (#3)

A privileged operator console reached as `ssh admin@host`, gated by route
plus the $AGENTBBS_ADMINS allowlist (admin status is operator-granted only,
never self-assigned in-band). It is a self-contained Bubble Tea model, not a
hub plugin, so it never appears in the public menu.

Sections (PRD §6):
  - Users & members: list accounts; b = suspend/ban (operators protected).
    Banned accounts are blocked at the hub and pod@ routes.
  - Sessions & pods: live in-memory session registry; k = disconnect.
  - Moderation & audit: admin action log + agent@ transcripts (tab to switch).
  - Config & plugins: runtime snapshot; space = enable/disable a plugin
    (persisted; filtered from the hub on next sign-in).

Every privileged action is written to a new admin_actions audit table.

store: + banned column, admin_actions and plugin_state tables, and the
backing methods (ListUsers/SetBanned/RecentSessions/LogAdminAction/
RecentAdminActions/RecentChatsAll/DisabledPlugins/SetPluginDisabled), with
unit tests. auth: admin allowlist helpers + tests. Docs in docs/admin.md;
README M2 flipped to done.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 02:40:18 -07:00 committed by GitHub
parent ceaf055e0e
commit 232b8151a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1174 additions and 5 deletions

179
cmd/agentbbs/admin.go Normal file
View file

@ -0,0 +1,179 @@
package main
import (
"sort"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/profullstack/agentbbs/internal/admin"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/plugin"
)
// liveReg is the in-memory registry of currently-connected SSH sessions. The
// DB sessions table is the historical audit trail; this is the live view the
// admin console lists and can disconnect (PRD §6 "terminate live sessions").
type liveReg struct {
mu sync.Mutex
next int64
m map[int64]liveEntry
}
type liveEntry struct {
s ssh.Session
user string
route string
start time.Time
}
func newLiveReg() *liveReg { return &liveReg{m: map[int64]liveEntry{}} }
func (r *liveReg) add(s ssh.Session) int64 {
r.mu.Lock()
defer r.mu.Unlock()
r.next++
id := r.next
r.m[id] = liveEntry{s: s, user: s.User(), route: routeLabel(s.User()), start: time.Now()}
return id
}
func (r *liveReg) remove(id int64) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.m, id)
}
// idFor returns the registry id of an active session, or 0 if absent.
func (r *liveReg) idFor(s ssh.Session) int64 {
r.mu.Lock()
defer r.mu.Unlock()
for id, e := range r.m {
if e.s == s {
return id
}
}
return 0
}
// List implements admin.LiveSessions, newest connection first.
func (r *liveReg) List() []admin.Live {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]admin.Live, 0, len(r.m))
for id, e := range r.m {
out = append(out, admin.Live{ID: id, User: e.user, Remote: remoteIP(e.s), Route: e.route, Start: e.start})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
return out
}
// Kill closes a live session by id. Returns false if it is already gone.
func (r *liveReg) Kill(id int64) bool {
r.mu.Lock()
e, ok := r.m[id]
r.mu.Unlock()
if !ok {
return false
}
_ = e.s.Close()
return true
}
// track registers every connection in the live registry for its lifetime.
func (a *app) track() wish.Middleware {
return func(next ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
id := a.live.add(s)
defer a.live.remove(id)
next(s)
}
}
}
// routeLabel classifies an SSH username into the route it dispatches to, for
// display in the admin sessions view.
func routeLabel(user string) string {
user = strings.ToLower(user)
switch {
case auth.IsJoinName(user):
return "join"
case auth.IsDomainName(user):
return "domain"
case auth.IsPodName(user):
return "pod"
case auth.IsAdminName(user):
return "admin"
case user == "agent":
return "agent"
}
if _, isVideo := calls.RouteCode(user); isVideo {
return "video"
}
return "hub"
}
// adminTeaHandler builds the admin console for an authorized operator. It
// re-resolves identity here (not just at the route) so a direct invocation is
// still safe: non-admins get a one-line notice and disconnect.
func (a *app) adminTeaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
fp := auth.Fingerprint(s.PublicKey())
var name string
if fp != "" {
if u, found, _ := a.st.UserByFingerprint(fp); found {
name = u.Name
}
}
if name == "" || !auth.IsAdmin(name) {
wish.Println(s, "admin@ is restricted to operators.")
_ = s.Exit(1)
return nil, nil
}
u := auth.User{Name: name, Kind: auth.Member, PubKeyFP: fp}
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "admin")
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
env := admin.Env{
Host: a.host,
Sandbox: string(a.sandbox.Mode()),
MailConfigured: a.mail.Configured(),
Admins: sortedKeys(auth.Admins()),
}
if a.pods != nil {
env.PodsEngine = a.pods.Engine()
}
for _, p := range a.registry {
env.Plugins = append(env.Plugins, admin.PluginInfo{ID: p.ID(), Title: p.Title()})
}
m := admin.New(u, a.st, a.live, a.live.idFor(s), env)
return m, []tea.ProgramOption{tea.WithAltScreen()}
}
func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// enabledPlugins is a.registry minus any plugin an admin has switched off.
func (a *app) enabledPlugins() []plugin.Plugin {
disabled, err := a.st.DisabledPlugins()
if err != nil || len(disabled) == 0 {
return a.registry
}
out := make([]plugin.Plugin, 0, len(a.registry))
for _, p := range a.registry {
if !disabled[p.ID()] {
out = append(out, p)
}
}
return out
}

View file

@ -8,6 +8,7 @@
// emailed code, then offers $10 lifetime Premium (CoinPay)
// ssh pod@host your personal Linux pod — free for verified members
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
//
// Subcommands:
//
@ -74,6 +75,7 @@ type app struct {
sandbox *sandbox.Runner
mail mail.Config
fe forwardemail.Config // premium @bbs email provisioning
live *liveReg // in-memory live-session registry (admin console)
dataDir string
assets string
host string // public hostname used in user-facing messages
@ -108,6 +110,7 @@ func main() {
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
mail: mail.ConfigFromEnv(),
fe: fe,
live: newLiveReg(),
dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"),
host: host,
@ -167,6 +170,7 @@ func main() {
wish.WithIdleTimeout(30*time.Minute),
wish.WithMiddleware(
a.router(),
a.track(), // register every session for the admin console
logging.Middleware(),
),
)
@ -194,8 +198,10 @@ func main() {
// a terminal (it prints and disconnects), and pod@ checks its PTY itself.
func (a *app) router() wish.Middleware {
btMw := bm.Middleware(a.teaHandler)
adminMw := bm.Middleware(a.adminTeaHandler)
return func(next ssh.Handler) ssh.Handler {
hubHandler := activeterm.Middleware()(btMw(next))
adminHandler := activeterm.Middleware()(adminMw(next))
return func(s ssh.Session) {
user := strings.ToLower(s.User())
code, isVideo := calls.RouteCode(user)
@ -204,6 +210,8 @@ func (a *app) router() wish.Middleware {
a.handleJoin(s)
case auth.IsDomainName(user):
a.handleDomain(s)
case auth.IsAdminName(user):
adminHandler(s)
case auth.IsPodName(user):
a.handlePod(s)
case isVideo:
@ -245,6 +253,10 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
wish.Fatalln(s, "account error: "+err.Error())
return nil, nil
}
if su.Banned {
wish.Fatalln(s, "this account is suspended. Contact an operator if you think this is a mistake.")
return nil, nil
}
if su.Name != username {
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
}
@ -266,7 +278,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
// URL works the moment a member first signs in.
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
}
return hub.New(u, ctx, a.registry), []tea.ProgramOption{tea.WithAltScreen()}
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
}
// handleJoin runs onboarding interactively in one SSH session: register the
@ -681,6 +693,11 @@ func (a *app) handlePod(s ssh.Session) {
_ = s.Exit(1)
return
}
if u.Banned {
wish.Println(s, "this account is suspended.")
_ = s.Exit(1)
return
}
// Pods are a FREE member benefit — the only gate is a confirmed email, so
// every registered member gets their own Docker pod (set
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to drop even that on a dev host).