Skip to content

Commit 232b815

Browse files
ralyodioclaude
andauthored
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>
1 parent ceaf055 commit 232b815

9 files changed

Lines changed: 1174 additions & 5 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ plugins around one shared account system; the full product plan is in
3131
| Pods (rootless containers, free for verified members) ||
3232
| Video (`video-<code>@`, PairUX/LiveKit → ASCII streaming) ||
3333
| `agent@` chat (configurable agent backend) + finger ||
34-
| M2 — admin console | |
34+
| M2 — admin console (`admin@`: users, sessions, moderation, plugins) | |
3535
| M3 — AgentGames (agent-vs-agent ladder; spec on logicsrc.com) ||
3636
| M4 — Files (cl1.tech SFTP workspaces) ||
3737
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) ||
@@ -53,6 +53,7 @@ Configuration (env):
5353
| `AGENTBBS_DATA` | `./data` | SQLite db, host key, per-user dirs |
5454
| `AGENTBBS_ASSETS` | `./assets` | doom binary + wads |
5555
| `AGENTBBS_HOST` | `bbs.profullstack.com` | hostname shown in messages |
56+
| `AGENTBBS_ADMINS` | unset | operator account names for `admin@` (comma/space-separated) — see [docs/admin.md](docs/admin.md) |
5657
| `AGENTBBS_SANDBOX` | `auto` | `bwrap` / `prlimit` / `none` |
5758
| `AGENTBBS_POD_IMAGE` | `debian:stable-slim` | pod base image |
5859
| `AGENTBBS_POD_MEM` / `AGENTBBS_POD_CPUS` | `512m` / `1` | pod caps |

cmd/agentbbs/admin.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package main
2+
3+
import (
4+
"sort"
5+
"strings"
6+
"sync"
7+
"time"
8+
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/ssh"
11+
"github.com/charmbracelet/wish"
12+
13+
"github.com/profullstack/agentbbs/internal/admin"
14+
"github.com/profullstack/agentbbs/internal/auth"
15+
"github.com/profullstack/agentbbs/internal/calls"
16+
"github.com/profullstack/agentbbs/internal/plugin"
17+
)
18+
19+
// liveReg is the in-memory registry of currently-connected SSH sessions. The
20+
// DB sessions table is the historical audit trail; this is the live view the
21+
// admin console lists and can disconnect (PRD §6 "terminate live sessions").
22+
type liveReg struct {
23+
mu sync.Mutex
24+
next int64
25+
m map[int64]liveEntry
26+
}
27+
28+
type liveEntry struct {
29+
s ssh.Session
30+
user string
31+
route string
32+
start time.Time
33+
}
34+
35+
func newLiveReg() *liveReg { return &liveReg{m: map[int64]liveEntry{}} }
36+
37+
func (r *liveReg) add(s ssh.Session) int64 {
38+
r.mu.Lock()
39+
defer r.mu.Unlock()
40+
r.next++
41+
id := r.next
42+
r.m[id] = liveEntry{s: s, user: s.User(), route: routeLabel(s.User()), start: time.Now()}
43+
return id
44+
}
45+
46+
func (r *liveReg) remove(id int64) {
47+
r.mu.Lock()
48+
defer r.mu.Unlock()
49+
delete(r.m, id)
50+
}
51+
52+
// idFor returns the registry id of an active session, or 0 if absent.
53+
func (r *liveReg) idFor(s ssh.Session) int64 {
54+
r.mu.Lock()
55+
defer r.mu.Unlock()
56+
for id, e := range r.m {
57+
if e.s == s {
58+
return id
59+
}
60+
}
61+
return 0
62+
}
63+
64+
// List implements admin.LiveSessions, newest connection first.
65+
func (r *liveReg) List() []admin.Live {
66+
r.mu.Lock()
67+
defer r.mu.Unlock()
68+
out := make([]admin.Live, 0, len(r.m))
69+
for id, e := range r.m {
70+
out = append(out, admin.Live{ID: id, User: e.user, Remote: remoteIP(e.s), Route: e.route, Start: e.start})
71+
}
72+
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
73+
return out
74+
}
75+
76+
// Kill closes a live session by id. Returns false if it is already gone.
77+
func (r *liveReg) Kill(id int64) bool {
78+
r.mu.Lock()
79+
e, ok := r.m[id]
80+
r.mu.Unlock()
81+
if !ok {
82+
return false
83+
}
84+
_ = e.s.Close()
85+
return true
86+
}
87+
88+
// track registers every connection in the live registry for its lifetime.
89+
func (a *app) track() wish.Middleware {
90+
return func(next ssh.Handler) ssh.Handler {
91+
return func(s ssh.Session) {
92+
id := a.live.add(s)
93+
defer a.live.remove(id)
94+
next(s)
95+
}
96+
}
97+
}
98+
99+
// routeLabel classifies an SSH username into the route it dispatches to, for
100+
// display in the admin sessions view.
101+
func routeLabel(user string) string {
102+
user = strings.ToLower(user)
103+
switch {
104+
case auth.IsJoinName(user):
105+
return "join"
106+
case auth.IsDomainName(user):
107+
return "domain"
108+
case auth.IsPodName(user):
109+
return "pod"
110+
case auth.IsAdminName(user):
111+
return "admin"
112+
case user == "agent":
113+
return "agent"
114+
}
115+
if _, isVideo := calls.RouteCode(user); isVideo {
116+
return "video"
117+
}
118+
return "hub"
119+
}
120+
121+
// adminTeaHandler builds the admin console for an authorized operator. It
122+
// re-resolves identity here (not just at the route) so a direct invocation is
123+
// still safe: non-admins get a one-line notice and disconnect.
124+
func (a *app) adminTeaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
125+
fp := auth.Fingerprint(s.PublicKey())
126+
var name string
127+
if fp != "" {
128+
if u, found, _ := a.st.UserByFingerprint(fp); found {
129+
name = u.Name
130+
}
131+
}
132+
if name == "" || !auth.IsAdmin(name) {
133+
wish.Println(s, "admin@ is restricted to operators.")
134+
_ = s.Exit(1)
135+
return nil, nil
136+
}
137+
u := auth.User{Name: name, Kind: auth.Member, PubKeyFP: fp}
138+
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "admin")
139+
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
140+
141+
env := admin.Env{
142+
Host: a.host,
143+
Sandbox: string(a.sandbox.Mode()),
144+
MailConfigured: a.mail.Configured(),
145+
Admins: sortedKeys(auth.Admins()),
146+
}
147+
if a.pods != nil {
148+
env.PodsEngine = a.pods.Engine()
149+
}
150+
for _, p := range a.registry {
151+
env.Plugins = append(env.Plugins, admin.PluginInfo{ID: p.ID(), Title: p.Title()})
152+
}
153+
m := admin.New(u, a.st, a.live, a.live.idFor(s), env)
154+
return m, []tea.ProgramOption{tea.WithAltScreen()}
155+
}
156+
157+
func sortedKeys(m map[string]bool) []string {
158+
out := make([]string, 0, len(m))
159+
for k := range m {
160+
out = append(out, k)
161+
}
162+
sort.Strings(out)
163+
return out
164+
}
165+
166+
// enabledPlugins is a.registry minus any plugin an admin has switched off.
167+
func (a *app) enabledPlugins() []plugin.Plugin {
168+
disabled, err := a.st.DisabledPlugins()
169+
if err != nil || len(disabled) == 0 {
170+
return a.registry
171+
}
172+
out := make([]plugin.Plugin, 0, len(a.registry))
173+
for _, p := range a.registry {
174+
if !disabled[p.ID()] {
175+
out = append(out, p)
176+
}
177+
}
178+
return out
179+
}

cmd/agentbbs/main.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// emailed code, then offers $10 lifetime Premium (CoinPay)
99
// ssh pod@host your personal Linux pod — free for verified members
1010
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
11+
// ssh admin@host the operator admin console ($AGENTBBS_ADMINS only)
1112
//
1213
// Subcommands:
1314
//
@@ -74,6 +75,7 @@ type app struct {
7475
sandbox *sandbox.Runner
7576
mail mail.Config
7677
fe forwardemail.Config // premium @bbs email provisioning
78+
live *liveReg // in-memory live-session registry (admin console)
7779
dataDir string
7880
assets string
7981
host string // public hostname used in user-facing messages
@@ -108,6 +110,7 @@ func main() {
108110
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
109111
mail: mail.ConfigFromEnv(),
110112
fe: fe,
113+
live: newLiveReg(),
111114
dataDir: dataDir,
112115
assets: env("AGENTBBS_ASSETS", "./assets"),
113116
host: host,
@@ -167,6 +170,7 @@ func main() {
167170
wish.WithIdleTimeout(30*time.Minute),
168171
wish.WithMiddleware(
169172
a.router(),
173+
a.track(), // register every session for the admin console
170174
logging.Middleware(),
171175
),
172176
)
@@ -194,8 +198,10 @@ func main() {
194198
// a terminal (it prints and disconnects), and pod@ checks its PTY itself.
195199
func (a *app) router() wish.Middleware {
196200
btMw := bm.Middleware(a.teaHandler)
201+
adminMw := bm.Middleware(a.adminTeaHandler)
197202
return func(next ssh.Handler) ssh.Handler {
198203
hubHandler := activeterm.Middleware()(btMw(next))
204+
adminHandler := activeterm.Middleware()(adminMw(next))
199205
return func(s ssh.Session) {
200206
user := strings.ToLower(s.User())
201207
code, isVideo := calls.RouteCode(user)
@@ -204,6 +210,8 @@ func (a *app) router() wish.Middleware {
204210
a.handleJoin(s)
205211
case auth.IsDomainName(user):
206212
a.handleDomain(s)
213+
case auth.IsAdminName(user):
214+
adminHandler(s)
207215
case auth.IsPodName(user):
208216
a.handlePod(s)
209217
case isVideo:
@@ -245,6 +253,10 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
245253
wish.Fatalln(s, "account error: "+err.Error())
246254
return nil, nil
247255
}
256+
if su.Banned {
257+
wish.Fatalln(s, "this account is suspended. Contact an operator if you think this is a mistake.")
258+
return nil, nil
259+
}
248260
if su.Name != username {
249261
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
250262
}
@@ -266,7 +278,7 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
266278
// URL works the moment a member first signs in.
267279
seedHomepage(filepath.Join(ctx.DataDir, "public_html"), u.Name, a.host)
268280
}
269-
return hub.New(u, ctx, a.registry), []tea.ProgramOption{tea.WithAltScreen()}
281+
return hub.New(u, ctx, a.enabledPlugins()), []tea.ProgramOption{tea.WithAltScreen()}
270282
}
271283

272284
// handleJoin runs onboarding interactively in one SSH session: register the
@@ -681,6 +693,11 @@ func (a *app) handlePod(s ssh.Session) {
681693
_ = s.Exit(1)
682694
return
683695
}
696+
if u.Banned {
697+
wish.Println(s, "this account is suspended.")
698+
_ = s.Exit(1)
699+
return
700+
}
684701
// Pods are a FREE member benefit — the only gate is a confirmed email, so
685702
// every registered member gets their own Docker pod (set
686703
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to drop even that on a dev host).

docs/admin.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Admin console (M2)
2+
3+
The admin console is a privileged operator surface reached over SSH:
4+
5+
```
6+
ssh admin@bbs.profullstack.com
7+
```
8+
9+
It is **not** a hub plugin — it never appears in the public menu. Access is
10+
gated by the route plus an operator allowlist, so a curious member who guesses
11+
the route still gets nothing.
12+
13+
## Who is an admin
14+
15+
Admin status is granted **only by the operator**, out of band, via an
16+
environment variable — it can never be self-assigned in-session:
17+
18+
```
19+
AGENTBBS_ADMINS="anthony,ops" # comma/space-separated account names
20+
```
21+
22+
To open the console you must:
23+
24+
1. connect as `admin@` (or `sysop@`), **and**
25+
2. present the SSH key of an account whose name is in `$AGENTBBS_ADMINS`.
26+
27+
Anyone else gets `admin@ is restricted to operators.` and is disconnected.
28+
29+
## Sections
30+
31+
The console is a Bubble Tea TUI. Arrow keys (or `j`/`k`) move, `enter` opens a
32+
section, `esc` goes back, `q` quits, `r` refreshes the current list.
33+
34+
### Users & members
35+
Lists accounts (newest first) with kind / premium / verified flags.
36+
37+
- `b` — ban/unban the selected account. Banned accounts are blocked at login
38+
on the hub and `pod@` routes. Operators cannot be banned.
39+
40+
### Sessions & pods
41+
The **live** view of currently-connected SSH sessions (the in-memory registry,
42+
distinct from the historical audit trail in the DB).
43+
44+
- `k` — disconnect the selected session (PRD §6 "terminate live sessions").
45+
Your own session is marked `(you)` and is protected.
46+
47+
### Moderation & audit
48+
- `tab` switches between the **admin action log** (every ban, kill, and plugin
49+
toggle, with who/when) and recent **`agent@` transcripts** for review.
50+
- Bans are taken from the Users section.
51+
52+
### Config & plugins
53+
- Read-only runtime snapshot: host, sandbox mode, pods engine, mail status,
54+
admin allowlist.
55+
- `space` toggles a hub plugin on/off. The change is persisted (`plugin_state`
56+
table) and takes effect on each member's next sign-in — disabled plugins are
57+
filtered out of the hub menu.
58+
59+
## Audit trail
60+
61+
Every privileged action is written to the `admin_actions` table
62+
(`admin, action, target, detail, created_at`) and is visible in the
63+
Moderation & audit section. Connection metadata continues to land in the
64+
`sessions` table as before.
65+
66+
## Persistence
67+
68+
| Concern | Where |
69+
|--------------------|----------------------------------------|
70+
| Suspensions | `users.banned` |
71+
| Plugin enable/disable | `plugin_state(id, disabled)` |
72+
| Admin action log | `admin_actions` |
73+
| Session audit trail| `sessions` (historical) |
74+
| Live sessions | in-memory registry (killable) |
75+
76+
## Not yet (future milestones)
77+
78+
- AgentAd ops (creative approval, ledgers) — lands with M5.
79+
- Live operator takeover of an `agent@` chat — the transcripts are reviewable
80+
here today; interactive takeover is future work.
81+
- Per-plugin config editing beyond enable/disable.

0 commit comments

Comments
 (0)