|
| 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 | +} |
0 commit comments