diff --git a/README.md b/README.md index 3f6f70c..9120eac 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ plugins around one shared account system; the full product plan is in | Pods (rootless containers, free for verified members) | ✅ | | Video (`video-@`, PairUX/LiveKit → ASCII streaming) | ✅ | | `agent@` chat (configurable agent backend) + finger | ✅ | -| M2 — admin console | ⬜ | +| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | ✅ | | M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) | ⬜ | | M4 — Files (cl1.tech SFTP workspaces) | ⬜ | | M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ | @@ -53,6 +53,7 @@ Configuration (env): | `AGENTBBS_DATA` | `./data` | SQLite db, host key, per-user dirs | | `AGENTBBS_ASSETS` | `./assets` | doom binary + wads | | `AGENTBBS_HOST` | `bbs.profullstack.com` | hostname shown in messages | +| `AGENTBBS_ADMINS` | unset | operator account names for `admin@` (comma/space-separated) — see [docs/admin.md](docs/admin.md) | | `AGENTBBS_SANDBOX` | `auto` | `bwrap` / `prlimit` / `none` | | `AGENTBBS_POD_IMAGE` | `debian:stable-slim` | pod base image | | `AGENTBBS_POD_MEM` / `AGENTBBS_POD_CPUS` | `512m` / `1` | pod caps | diff --git a/cmd/agentbbs/admin.go b/cmd/agentbbs/admin.go new file mode 100644 index 0000000..667b004 --- /dev/null +++ b/cmd/agentbbs/admin.go @@ -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 +} diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index 492c905..261bfb1 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -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). diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..e1107cf --- /dev/null +++ b/docs/admin.md @@ -0,0 +1,81 @@ +# Admin console (M2) + +The admin console is a privileged operator surface reached over SSH: + +``` +ssh admin@bbs.profullstack.com +``` + +It is **not** a hub plugin — it never appears in the public menu. Access is +gated by the route plus an operator allowlist, so a curious member who guesses +the route still gets nothing. + +## Who is an admin + +Admin status is granted **only by the operator**, out of band, via an +environment variable — it can never be self-assigned in-session: + +``` +AGENTBBS_ADMINS="anthony,ops" # comma/space-separated account names +``` + +To open the console you must: + +1. connect as `admin@` (or `sysop@`), **and** +2. present the SSH key of an account whose name is in `$AGENTBBS_ADMINS`. + +Anyone else gets `admin@ is restricted to operators.` and is disconnected. + +## Sections + +The console is a Bubble Tea TUI. Arrow keys (or `j`/`k`) move, `enter` opens a +section, `esc` goes back, `q` quits, `r` refreshes the current list. + +### Users & members +Lists accounts (newest first) with kind / premium / verified flags. + +- `b` — ban/unban the selected account. Banned accounts are blocked at login + on the hub and `pod@` routes. Operators cannot be banned. + +### Sessions & pods +The **live** view of currently-connected SSH sessions (the in-memory registry, +distinct from the historical audit trail in the DB). + +- `k` — disconnect the selected session (PRD §6 "terminate live sessions"). + Your own session is marked `(you)` and is protected. + +### Moderation & audit +- `tab` switches between the **admin action log** (every ban, kill, and plugin + toggle, with who/when) and recent **`agent@` transcripts** for review. +- Bans are taken from the Users section. + +### Config & plugins +- Read-only runtime snapshot: host, sandbox mode, pods engine, mail status, + admin allowlist. +- `space` toggles a hub plugin on/off. The change is persisted (`plugin_state` + table) and takes effect on each member's next sign-in — disabled plugins are + filtered out of the hub menu. + +## Audit trail + +Every privileged action is written to the `admin_actions` table +(`admin, action, target, detail, created_at`) and is visible in the +Moderation & audit section. Connection metadata continues to land in the +`sessions` table as before. + +## Persistence + +| Concern | Where | +|--------------------|----------------------------------------| +| Suspensions | `users.banned` | +| Plugin enable/disable | `plugin_state(id, disabled)` | +| Admin action log | `admin_actions` | +| Session audit trail| `sessions` (historical) | +| Live sessions | in-memory registry (killable) | + +## Not yet (future milestones) + +- AgentAd ops (creative approval, ledgers) — lands with M5. +- Live operator takeover of an `agent@` chat — the transcripts are reviewable + here today; interactive takeover is future work. +- Per-plugin config editing beyond enable/disable. diff --git a/internal/admin/admin.go b/internal/admin/admin.go new file mode 100644 index 0000000..a67adda --- /dev/null +++ b/internal/admin/admin.go @@ -0,0 +1,450 @@ +// Package admin is the privileged admin console (PRD §6), reached over SSH as +// `ssh admin@host` by an account in the operator allowlist ($AGENTBBS_ADMINS). +// +// It is a self-contained Bubble Tea model (not a hub plugin) so it never shows +// up in the public menu: the route gates access, the model drives the console. +// Four sections cover the M2 scope — users, live sessions, moderation/audit, +// and config/plugins — over the shared store plus a live-session registry. +package admin + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/profullstack/agentbbs/internal/auth" + "github.com/profullstack/agentbbs/internal/store" +) + +// Live is one connected SSH session, as seen by the registry. +type Live struct { + ID int64 + User string + Remote string + Route string + Start time.Time +} + +// LiveSessions is the registry of currently-connected sessions the console can +// list and disconnect. main wires the concrete implementation. +type LiveSessions interface { + List() []Live + Kill(id int64) bool +} + +// PluginInfo identifies a registered hub plugin for the config screen. +type PluginInfo struct{ ID, Title string } + +// Env is the read-only runtime snapshot shown on the config screen. +type Env struct { + Host string + Sandbox string + PodsEngine string // "" when pods are disabled on this host + MailConfigured bool + Admins []string + Plugins []PluginInfo +} + +type screen int + +const ( + screenMenu screen = iota + screenUsers + screenSessions + screenAudit + screenConfig +) + +var menuItems = []struct { + screen screen + label string + desc string +}{ + {screenUsers, "Users & members", "List accounts, suspend/ban, inspect"}, + {screenSessions, "Sessions & pods", "Live connections — disconnect abusers"}, + {screenAudit, "Moderation & audit", "Admin action log + agent@ transcripts"}, + {screenConfig, "Config & plugins", "Runtime config; enable/disable plugins"}, +} + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80")) + headStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#60a5fa")) + frameStyle = lipgloss.NewStyle().Padding(1, 2) +) + +// Model is the admin console. +type Model struct { + admin auth.User + st store.Store + live LiveSessions + selfLive int64 // the admin's own live-session id, never killed + env Env + + screen screen + cursor int + note string + width int + height int + + // loaded per-screen data + users []store.User + sessions []Live + actions []store.AdminAction + chats []store.ChatRow + disabled map[string]bool + auditTab int // 0 = admin actions, 1 = agent@ chats +} + +// New builds the admin console for one session. +func New(admin auth.User, st store.Store, live LiveSessions, selfLive int64, env Env) Model { + return Model{admin: admin, st: st, live: live, selfLive: selfLive, env: env, disabled: map[string]bool{}} +} + +func (m Model) Init() tea.Cmd { return nil } + +func (m *Model) log(action, target, detail string) { + _ = m.st.LogAdminAction(m.admin.Name, action, target, detail) +} + +// load refreshes the data backing the current screen. +func (m *Model) load() { + m.cursor = 0 + switch m.screen { + case screenUsers: + m.users, _ = m.st.ListUsers(200) + case screenSessions: + m.sessions = m.live.List() + case screenAudit: + m.actions, _ = m.st.RecentAdminActions(100) + m.chats, _ = m.st.RecentChatsAll(100) + case screenConfig: + m.disabled, _ = m.st.DisabledPlugins() + } +} + +// rowCount is the number of selectable rows on the active screen. +func (m Model) rowCount() int { + switch m.screen { + case screenUsers: + return len(m.users) + case screenSessions: + return len(m.sessions) + case screenConfig: + return len(m.env.Plugins) + default: + return 0 + } +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + return m, nil + case tea.KeyMsg: + m.note = "" + switch msg.String() { + case "ctrl+c", "Q": + return m, tea.Quit + } + if m.screen == screenMenu { + return m.updateMenu(msg) + } + return m.updateScreen(msg) + } + return m, nil +} + +func (m Model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(menuItems)-1 { + m.cursor++ + } + case "enter", "right", "l": + m.screen = menuItems[m.cursor].screen + m.load() + } + return m, nil +} + +func (m Model) updateScreen(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc", "left", "h", "backspace": + m.screen = screenMenu + m.cursor = 0 + return m, nil + case "r": + m.load() + m.note = "refreshed" + return m, nil + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + case "down", "j": + if m.cursor < m.rowCount()-1 { + m.cursor++ + } + return m, nil + } + + switch m.screen { + case screenUsers: + return m.updateUsers(msg) + case screenSessions: + return m.updateSessions(msg) + case screenAudit: + return m.updateAudit(msg) + case screenConfig: + return m.updateConfig(msg) + } + return m, nil +} + +func (m Model) updateUsers(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() != "b" || m.cursor >= len(m.users) { + return m, nil + } + u := m.users[m.cursor] + if auth.IsAdmin(u.Name) { + m.note = warnStyle.Render("refusing to ban an operator (" + u.Name + ")") + return m, nil + } + ban := !u.Banned + if err := m.st.SetBanned(u.ID, ban); err != nil { + m.note = warnStyle.Render("ban failed: " + err.Error()) + return m, nil + } + action := "unban" + if ban { + action = "ban" + } + m.log(action, u.Name, "") + m.users[m.cursor].Banned = ban + m.note = okStyle.Render(action + "ned " + u.Name) + return m, nil +} + +func (m Model) updateSessions(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() != "k" || m.cursor >= len(m.sessions) { + return m, nil + } + s := m.sessions[m.cursor] + if s.ID == m.selfLive { + m.note = warnStyle.Render("that's your own session") + return m, nil + } + if m.live.Kill(s.ID) { + m.log("kill-session", s.User, s.Remote+" "+s.Route) + m.note = okStyle.Render("disconnected " + s.User + " (" + s.Route + ")") + } else { + m.note = warnStyle.Render("session already gone") + } + m.sessions = m.live.List() + if m.cursor >= len(m.sessions) && m.cursor > 0 { + m.cursor-- + } + return m, nil +} + +func (m Model) updateAudit(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "tab" { + m.auditTab = 1 - m.auditTab + } + return m, nil +} + +func (m Model) updateConfig(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if (msg.String() != " " && msg.String() != "enter") || m.cursor >= len(m.env.Plugins) { + return m, nil + } + p := m.env.Plugins[m.cursor] + disable := !m.disabled[p.ID] + if err := m.st.SetPluginDisabled(p.ID, disable); err != nil { + m.note = warnStyle.Render("toggle failed: " + err.Error()) + return m, nil + } + m.disabled[p.ID] = disable + action := "enable-plugin" + state := "enabled" + if disable { + action, state = "disable-plugin", "disabled" + } + m.log(action, p.ID, "") + m.note = okStyle.Render(p.Title + " " + state + " (takes effect on next sign-in)") + return m, nil +} + +func (m Model) View() string { + var body, help string + switch m.screen { + case screenMenu: + body, help = m.viewMenu() + case screenUsers: + body, help = m.viewUsers() + case screenSessions: + body, help = m.viewSessions() + case screenAudit: + body, help = m.viewAudit() + case screenConfig: + body, help = m.viewConfig() + } + + header := titleStyle.Render("AgentBBS admin") + dimStyle.Render(" · "+m.admin.Name) + out := header + "\n\n" + body + "\n" + dimStyle.Render(help) + if m.note != "" { + out += "\n" + m.note + } + return frameStyle.Render(out) +} + +func (m Model) viewMenu() (string, string) { + var b strings.Builder + for i, it := range menuItems { + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("> ") + } + fmt.Fprintf(&b, "%s%s\n %s\n", cur, it.label, dimStyle.Render(it.desc)) + } + return b.String(), "↑/↓ move · enter open · q quit" +} + +func (m Model) viewUsers() (string, string) { + var b strings.Builder + b.WriteString(headStyle.Render(fmt.Sprintf("Users (%d)", len(m.users))) + "\n\n") + if len(m.users) == 0 { + b.WriteString(dimStyle.Render(" (no accounts yet)\n")) + } + for i, u := range m.users { + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("> ") + } + flags := []string{u.Kind} + if u.Premium { + flags = append(flags, "premium") + } + if u.EmailVerified { + flags = append(flags, "verified") + } + name := u.Name + if u.Banned { + name = warnStyle.Render(name + " [BANNED]") + } + fmt.Fprintf(&b, "%s%-20s %s\n", cur, name, dimStyle.Render(strings.Join(flags, " · "))) + } + return b.String(), "↑/↓ move · b ban/unban · r refresh · esc back" +} + +func (m Model) viewSessions() (string, string) { + var b strings.Builder + b.WriteString(headStyle.Render(fmt.Sprintf("Live sessions (%d)", len(m.sessions))) + "\n\n") + if len(m.sessions) == 0 { + b.WriteString(dimStyle.Render(" (nobody connected)\n")) + } + for i, s := range m.sessions { + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("> ") + } + who := s.User + if s.ID == m.selfLive { + who += " (you)" + } + age := time.Since(s.Start).Round(time.Second) + fmt.Fprintf(&b, "%s%-18s %-8s %-16s %s\n", cur, who, s.Route, s.Remote, dimStyle.Render(age.String())) + } + return b.String(), "↑/↓ move · k disconnect · r refresh · esc back" +} + +func (m Model) viewAudit() (string, string) { + var b strings.Builder + tabs := "[ admin actions ] agent@ chats" + if m.auditTab == 1 { + tabs = " admin actions [ agent@ chats ]" + } + b.WriteString(headStyle.Render("Moderation & audit") + " " + dimStyle.Render(tabs) + "\n\n") + if m.auditTab == 0 { + if len(m.actions) == 0 { + b.WriteString(dimStyle.Render(" (no admin actions logged yet)\n")) + } + for _, a := range m.actions { + line := fmt.Sprintf(" %s %s %s", a.At.Local().Format("01-02 15:04"), a.Admin, a.Action) + if a.Target != "" { + line += " → " + a.Target + } + if a.Detail != "" { + line += " " + dimStyle.Render(a.Detail) + } + b.WriteString(line + "\n") + } + } else { + if len(m.chats) == 0 { + b.WriteString(dimStyle.Render(" (no agent@ messages yet)\n")) + } + for _, c := range m.chats { + text := strings.ReplaceAll(c.Text, "\n", " ") + if len(text) > 60 { + text = text[:60] + "…" + } + fmt.Fprintf(&b, " %s %-12s %-5s %s\n", + c.At.Local().Format("01-02 15:04"), c.Username, c.Role, dimStyle.Render(text)) + } + } + return b.String(), "tab switch view · r refresh · esc back · (ban from Users)" +} + +func (m Model) viewConfig() (string, string) { + var b strings.Builder + b.WriteString(headStyle.Render("Runtime config") + "\n") + pods := m.env.PodsEngine + if pods == "" { + pods = warnStyle.Render("disabled") + } + mail := "configured" + if !m.env.MailConfigured { + mail = warnStyle.Render("not configured") + } + admins := strings.Join(m.env.Admins, ", ") + if admins == "" { + admins = warnStyle.Render("(none — set AGENTBBS_ADMINS)") + } + fmt.Fprintf(&b, " host %s\n", m.env.Host) + fmt.Fprintf(&b, " sandbox %s\n", m.env.Sandbox) + fmt.Fprintf(&b, " pods %s\n", pods) + fmt.Fprintf(&b, " mail %s\n", mail) + fmt.Fprintf(&b, " admins %s\n", admins) + + b.WriteString("\n" + headStyle.Render("Plugins") + "\n\n") + for i, p := range m.env.Plugins { + cur := " " + if i == m.cursor { + cur = cursorStyle.Render("> ") + } + state := okStyle.Render("on ") + if m.disabled[p.ID] { + state = warnStyle.Render("off") + } + fmt.Fprintf(&b, "%s[%s] %-12s %s\n", cur, state, p.ID, dimStyle.Render(p.Title)) + } + if len(m.env.Plugins) == 0 { + b.WriteString(dimStyle.Render(" (no plugins registered)\n")) + } + return b.String(), "↑/↓ move · space toggle · esc back" +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 76b292b..3c96086 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( + "os" "strings" "github.com/charmbracelet/ssh" @@ -40,6 +41,11 @@ var JoinNames = map[string]bool{"join": true, "signup": true, "register": true} // 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} + // IsGuestName reports whether the SSH username requests anonymous hub access. func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] } @@ -52,6 +58,25 @@ 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)] } + +// 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 { diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..08c8898 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,42 @@ +package auth + +import "testing" + +func TestIsAdminName(t *testing.T) { + for _, name := range []string{"admin", "ADMIN", "sysop"} { + if !IsAdminName(name) { + t.Errorf("IsAdminName(%q) = false, want true", name) + } + } + for _, name := range []string{"bbs", "pod", "anthony", ""} { + if IsAdminName(name) { + t.Errorf("IsAdminName(%q) = true, want false", name) + } + } +} + +func TestAdminsAllowlist(t *testing.T) { + t.Setenv("AGENTBBS_ADMINS", "anthony, Root ops") + admins := Admins() + for _, want := range []string{"anthony", "root", "ops"} { + if !admins[want] { + t.Errorf("expected %q in allowlist, got %v", want, admins) + } + } + if !IsAdmin("ANTHONY") { + t.Error("IsAdmin should be case-insensitive") + } + if IsAdmin("eve") { + t.Error("eve must not be an admin") + } +} + +func TestAdminsEmpty(t *testing.T) { + t.Setenv("AGENTBBS_ADMINS", "") + if len(Admins()) != 0 { + t.Error("empty env should yield no admins") + } + if IsAdmin("anyone") { + t.Error("nobody is admin when allowlist is empty") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index fdf2a22..40eea83 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -20,23 +20,25 @@ type User struct { EmailVerified bool Premium bool // paid the one-time lifetime membership PremiumPayID string // CoinPay payment id of the pending/settled premium charge + Banned bool // suspended by an admin (blocked at login) CreatedAt time.Time } // userCols is the column list (in struct order) for every user SELECT, kept in // sync with scanUser. -const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, created_at` +const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, premium_pay_id, banned, created_at` // scanUser reads one user row selected with userCols. func scanUser(sc interface{ Scan(...any) error }) (User, error) { var u User - var verified, premium int + var verified, premium, banned int var created string - if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &created); err != nil { + if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &u.PremiumPayID, &banned, &created); err != nil { return User{}, err } u.EmailVerified = verified != 0 u.Premium = premium != 0 + u.Banned = banned != 0 u.CreatedAt, _ = time.Parse(time.RFC3339, created) return u, nil } @@ -105,9 +107,58 @@ type Store interface { DomainsForUser(username string) ([]string, error) AllDomains() ([]DomainMap, error) + // Admin console (PRD §6). All read-only listings are newest-first. + + // ListUsers returns up to limit accounts, most recently created first. + ListUsers(limit int) ([]User, error) + // SetBanned suspends (or restores) an account; banned accounts are blocked + // at login by the SSH routes. + SetBanned(userID int64, banned bool) error + // RecentSessions returns the last n session rows (the audit trail). + RecentSessions(n int) ([]SessionRow, error) + // LogAdminAction records one privileged action for the audit log. + LogAdminAction(admin, action, target, detail string) error + // RecentAdminActions returns the last n logged admin actions. + RecentAdminActions(n int) ([]AdminAction, error) + // RecentChatsAll returns the last n agent@ messages across all users, for + // moderation review. + RecentChatsAll(n int) ([]ChatRow, error) + // DisabledPlugins reports the set of plugin IDs currently switched off. + DisabledPlugins() (map[string]bool, error) + // SetPluginDisabled enables or disables a plugin by ID. Idempotent. + SetPluginDisabled(id string, disabled bool) error + Close() error } +// SessionRow is one connection record from the audit trail. +type SessionRow struct { + ID int64 + Username string + Remote string + Route string + Started time.Time + Ended time.Time + EndedValid bool // false while the session is still open +} + +// AdminAction is one entry in the admin audit log. +type AdminAction struct { + Admin string + Action string + Target string + Detail string + At time.Time +} + +// ChatRow is one agent@ message with its author, for moderation review. +type ChatRow struct { + Username string + Role string + Text string + At time.Time +} + // ChatMessage is one line of an agent@ conversation. type ChatMessage struct { Role string // "user" or "agent" @@ -159,6 +210,7 @@ func migrate(db *sql.DB) error { {"premium", "premium INTEGER NOT NULL DEFAULT 0"}, {"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"}, {"premium_pay_id", "premium_pay_id TEXT NOT NULL DEFAULT ''"}, + {"banned", "banned INTEGER NOT NULL DEFAULT 0"}, }) } @@ -239,6 +291,20 @@ CREATE TABLE IF NOT EXISTS domains ( created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) ); CREATE INDEX IF NOT EXISTS idx_domains_user ON domains(username); +CREATE TABLE IF NOT EXISTS admin_actions ( + id INTEGER PRIMARY KEY, + admin TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); +CREATE INDEX IF NOT EXISTS idx_admin_actions_id ON admin_actions(id DESC); +CREATE TABLE IF NOT EXISTS plugin_state ( + id TEXT PRIMARY KEY, + disabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); ` func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { @@ -523,4 +589,146 @@ func (s *sqliteStore) AllDomains() ([]DomainMap, error) { return out, rows.Err() } +func (s *sqliteStore) ListUsers(limit int) ([]User, error) { + if limit <= 0 { + limit = 100 + } + rows, err := s.db.Query(`SELECT `+userCols+` FROM users ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *sqliteStore) SetBanned(userID int64, banned bool) error { + b := 0 + if banned { + b = 1 + } + _, err := s.db.Exec(`UPDATE users SET banned = ? WHERE id = ?`, b, userID) + return err +} + +func (s *sqliteStore) RecentSessions(n int) ([]SessionRow, error) { + if n <= 0 { + n = 50 + } + rows, err := s.db.Query(` + SELECT id, username, remote_addr, route, started_at, ended_at + FROM sessions ORDER BY id DESC LIMIT ?`, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []SessionRow + for rows.Next() { + var r SessionRow + var started string + var ended sql.NullString + if err := rows.Scan(&r.ID, &r.Username, &r.Remote, &r.Route, &started, &ended); err != nil { + return nil, err + } + r.Started, _ = time.Parse(time.RFC3339, started) + if ended.Valid && ended.String != "" { + r.Ended, _ = time.Parse(time.RFC3339, ended.String) + r.EndedValid = true + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *sqliteStore) LogAdminAction(admin, action, target, detail string) error { + _, err := s.db.Exec(`INSERT INTO admin_actions (admin, action, target, detail) VALUES (?,?,?,?)`, + admin, action, target, detail) + return err +} + +func (s *sqliteStore) RecentAdminActions(n int) ([]AdminAction, error) { + if n <= 0 { + n = 50 + } + rows, err := s.db.Query(` + SELECT admin, action, target, detail, created_at + FROM admin_actions ORDER BY id DESC LIMIT ?`, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AdminAction + for rows.Next() { + var a AdminAction + var at string + if err := rows.Scan(&a.Admin, &a.Action, &a.Target, &a.Detail, &at); err != nil { + return nil, err + } + a.At, _ = time.Parse(time.RFC3339, at) + out = append(out, a) + } + return out, rows.Err() +} + +func (s *sqliteStore) RecentChatsAll(n int) ([]ChatRow, error) { + if n <= 0 { + n = 50 + } + rows, err := s.db.Query(` + SELECT username, role, text, created_at + FROM chat_messages ORDER BY id DESC LIMIT ?`, n) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ChatRow + for rows.Next() { + var c ChatRow + var at string + if err := rows.Scan(&c.Username, &c.Role, &c.Text, &at); err != nil { + return nil, err + } + c.At, _ = time.Parse(time.RFC3339, at) + out = append(out, c) + } + return out, rows.Err() +} + +func (s *sqliteStore) DisabledPlugins() (map[string]bool, error) { + rows, err := s.db.Query(`SELECT id FROM plugin_state WHERE disabled = 1`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + out[id] = true + } + return out, rows.Err() +} + +func (s *sqliteStore) SetPluginDisabled(id string, disabled bool) error { + d := 0 + if disabled { + d = 1 + } + _, err := s.db.Exec(` + INSERT INTO plugin_state (id, disabled) VALUES (?,?) + ON CONFLICT(id) DO UPDATE SET + disabled = excluded.disabled, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, id, d) + return err +} + func (s *sqliteStore) Close() error { return s.db.Close() } diff --git a/internal/store/store_admin_test.go b/internal/store/store_admin_test.go new file mode 100644 index 0000000..62ba4c5 --- /dev/null +++ b/internal/store/store_admin_test.go @@ -0,0 +1,166 @@ +package store + +import ( + "path/filepath" + "testing" +) + +func openTest(t *testing.T) Store { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func TestBanAndListUsers(t *testing.T) { + st := openTest(t) + + a, _ := st.EnsureUser("alice", "member", "SHA256:aaa") + if a.Banned { + t.Fatal("new user must not be banned") + } + _, _ = st.EnsureUser("bob", "member", "SHA256:bbb") + + users, err := st.ListUsers(10) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(users) != 2 { + t.Fatalf("want 2 users, got %d", len(users)) + } + // Newest-first ordering. + if users[0].Name != "bob" { + t.Fatalf("want bob first, got %s", users[0].Name) + } + + if err := st.SetBanned(a.ID, true); err != nil { + t.Fatalf("ban: %v", err) + } + got, _, _ := st.UserByFingerprint("SHA256:aaa") + if !got.Banned { + t.Fatal("alice should be banned") + } + if err := st.SetBanned(a.ID, false); err != nil { + t.Fatalf("unban: %v", err) + } + got, _, _ = st.UserByFingerprint("SHA256:aaa") + if got.Banned { + t.Fatal("alice should be unbanned") + } +} + +func TestAdminAuditLog(t *testing.T) { + st := openTest(t) + + if acts, _ := st.RecentAdminActions(10); len(acts) != 0 { + t.Fatalf("expected empty log, got %d", len(acts)) + } + if err := st.LogAdminAction("anthony", "ban", "spammer", "abuse"); err != nil { + t.Fatalf("log: %v", err) + } + _ = st.LogAdminAction("anthony", "disable-plugin", "arcade", "") + + acts, err := st.RecentAdminActions(10) + if err != nil { + t.Fatalf("recent: %v", err) + } + if len(acts) != 2 { + t.Fatalf("want 2 actions, got %d", len(acts)) + } + // Newest-first. + if acts[0].Action != "disable-plugin" || acts[0].Target != "arcade" { + t.Fatalf("unexpected first action: %+v", acts[0]) + } + if acts[1].Admin != "anthony" || acts[1].Detail != "abuse" { + t.Fatalf("unexpected second action: %+v", acts[1]) + } +} + +func TestRecentSessions(t *testing.T) { + st := openTest(t) + + u, _ := st.EnsureUser("carol", "member", "SHA256:ccc") + id, err := st.RecordSession(u.ID, "carol", "1.2.3.4", "hub") + if err != nil { + t.Fatalf("record: %v", err) + } + _, _ = st.RecordSession(0, "bbs", "5.6.7.8", "hub") + + rows, err := st.RecentSessions(10) + if err != nil { + t.Fatalf("recent: %v", err) + } + if len(rows) != 2 { + t.Fatalf("want 2 sessions, got %d", len(rows)) + } + // The first recorded session is still open until ended. + for _, r := range rows { + if r.ID == id && r.EndedValid { + t.Fatal("open session should not have an end time") + } + } + if err := st.EndSession(id); err != nil { + t.Fatalf("end: %v", err) + } + rows, _ = st.RecentSessions(10) + var found bool + for _, r := range rows { + if r.ID == id { + found = true + if !r.EndedValid { + t.Fatal("ended session should have an end time") + } + } + } + if !found { + t.Fatal("ended session missing from recent list") + } +} + +func TestPluginState(t *testing.T) { + st := openTest(t) + + if d, _ := st.DisabledPlugins(); len(d) != 0 { + t.Fatalf("expected nothing disabled, got %v", d) + } + if err := st.SetPluginDisabled("arcade", true); err != nil { + t.Fatalf("disable: %v", err) + } + d, err := st.DisabledPlugins() + if err != nil { + t.Fatalf("list: %v", err) + } + if !d["arcade"] { + t.Fatal("arcade should be disabled") + } + // Idempotent re-enable. + if err := st.SetPluginDisabled("arcade", false); err != nil { + t.Fatalf("enable: %v", err) + } + if d, _ := st.DisabledPlugins(); d["arcade"] { + t.Fatal("arcade should be enabled again") + } +} + +func TestRecentChatsAll(t *testing.T) { + st := openTest(t) + + u, _ := st.EnsureUser("dave", "member", "SHA256:ddd") + _ = st.AddChat(u.ID, "dave", "user", "hello there") + _ = st.AddChat(u.ID, "dave", "agent", "hi dave") + + chats, err := st.RecentChatsAll(10) + if err != nil { + t.Fatalf("recent chats: %v", err) + } + if len(chats) != 2 { + t.Fatalf("want 2 chats, got %d", len(chats)) + } + // Newest-first. + if chats[0].Role != "agent" || chats[0].Username != "dave" { + t.Fatalf("unexpected first chat: %+v", chats[0]) + } +}