mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
ceaf055e0e
commit
232b8151a2
9 changed files with 1174 additions and 5 deletions
179
cmd/agentbbs/admin.go
Normal file
179
cmd/agentbbs/admin.go
Normal 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
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue