Skip to content

Commit 2d6343e

Browse files
committed
agent: manage SSH sessions from dashboard
1 parent efb9724 commit 2d6343e

18 files changed

Lines changed: 690 additions & 177 deletions

‎docs/safety.es.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ pasa por el mismo motor:
6666
7. Verificar el estado del backend cuando corresponda y ejecutar el postflight
6767
requerido para start/restart/reload/resume.
6868

69+
El **close SSH session** del panel es una operación manual separada del motor,
70+
nunca una acción de regla ni remediación automática. Toma los mismos locks de
71+
operación y nombrados, guards, timeout y ruta de un resultado/evento, pero no
72+
reinicia ni ejecuta postflight del daemon SSH. Justo antes de la única señal,
73+
Sermo vuelve a leer el terminal con login y su ascendencia `/proc` hasta un
74+
ejecutable `sshd` configurado exacto y su usuario real, y exige el mismo
75+
terminal, PID de sesión y ticks de inicio del proceso. Si falta esa frontera, el
76+
terminal cambió o el PID se recicló, se rechaza. Un cierre correcto envía un único `SIGTERM` al proceso de
77+
sesión; nunca escala a `SIGKILL`.
78+
6979
Un residual que Sermo no tiene permitido identificar y matar se **reporta, no se mata**:
7080
un fallo limpio `orphan_processes` es más seguro que matar el proceso equivocado.
7181

‎docs/safety.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ runs through the same engine:
6565
7. Verify backend status where applicable and run required postflight for
6666
start/restart/reload/resume.
6767

68+
The dashboard's **close SSH session** is a separate manual engine operation,
69+
never a rule action or automatic remediation. It takes the same operation and
70+
named locks, guards, timeout and one-result event path, but does not restart or
71+
postflight the SSH daemon. Immediately before the only signal, Sermo re-reads
72+
the logged-in terminal and its `/proc` ancestry to an exact configured `sshd`
73+
executable and real user, and requires the same terminal, session PID and
74+
process start ticks.
75+
Any missing boundary, changed terminal or recycled PID is rejected. A successful
76+
close sends one `SIGTERM` to the per-session process; it never escalates to
77+
`SIGKILL`.
78+
6879
A residual Sermo is not allowed to identify and kill is **reported, not killed**:
6980
a clean `orphan_processes` failure is safer than killing the wrong process.
7081

‎docs/webui-representation.es.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ con un cuerpo `{"ok": bool, "message": string}` para una acción atendida.
160160
| --- | --- | --- |
161161
| Acción de servicio | `POST /api/services/{name}/{action}[?no_cascade=1]` | `monitor`, `unmonitor`, `start`, `stop`, `restart`, `reload`, `resume`; `reload` se ofrece solo cuando el servicio informa `can_reload` desde soporte de reload del backend de init o desde un fallback `reload:` válido; `no_cascade` omite los objetivos de `also_apply` en start/stop/restart |
162162
| Preflight de servicio | `POST /api/services/{name}/preflight` | ejecuta los checks de preflight sin cambiar el estado del servicio |
163+
| Cerrar sesión SSH | `POST /api/services/{name}/sessions/{pid}/close?start_ticks=TICKS&terminal=PTS` | solo admin y con confirmación: cierre elegante de un terminal SSH mostrado; el backend redescubre el terminal, el ejecutable `sshd` configurado exacto y su usuario real, exige el mismo PID y ticks de inicio y solo envía `SIGTERM` |
163164
| Acción de watch | `POST /api/watches/{name}/{action}` | `monitor`, `unmonitor`, `expand`, `probe` (una muestra manual), más `pause`/`resume` de RAID, que ejecutan una operación de re-chequeo y verificación y requieren la cabecera `X-Sermo-Confirm` |
164165
| Prueba de notifier | `POST /api/notifiers/{name}/test` | envía una notificación de prueba por el notifier nombrado tras confirmación |
165166
| Acción de montaje | `POST /api/mounts/{name}/{action}[?force=1&lazy=1&kill=1]` | `mount`, `umount`, `alert`; `force=1` permite `umount -f`, `lazy=1` permite `umount -l` como último fallback y `kill=1` habilita señalización de blockers limitada por `kill_only_if`; `/` rechaza las rutas de desmontaje |
@@ -180,6 +181,7 @@ con un cuerpo `{"ok": bool, "message": string}` para una acción atendida.
180181
| Refresco | selector con intervalo de refresco, botón de refresco manual |
181182
| Notificaciones | campana de notificaciones del navegador (opt-in); con el permiso concedido, los objetivos que empiezan a fallar generan una única notificación agrupada mientras la pestaña está oculta |
182183
| Estado | antigüedad del último refresco completo, errores de conexión o lista de paneles que conservan datos anteriores tras un refresco parcial; `#statusbar` termina con el `uptime:` del host y luego el `status:` del daemon (`ok` / `starting` / …) como una cola emparejada |
184+
| Sesiones | cuando se puede atribuir con seguridad un servicio SSH configurado, `sessions (console/SSH): X/Z` es el número de terminales locales y SSH con login; reemplaza el recuento anterior de usuarios distintos, así que tres terminales de la misma cuenta se ven como `0/3`, no como `1` |
183185
| Estado del sistema | identidad del host, tipo de host, resumen de daemon/backend/runtime |
184186

185187
Notas editables:
@@ -308,6 +310,7 @@ Compartida por los paneles Services, Containers y Virtual machines:
308310
| Datos generales | una cuadrícula sin encabezado, primera área de la expansión: nombre, estado, categoría, unidad/backend, uptime, intervalo, política, locks, último evento, próxima remediación, estado de remediación y totales del proceso; mientras la insignia de la fila sea `starting`, la expansión puede mostrar todavía el backend de init en bruto (`inactive`) y muestras de check en curso del ciclo de solo observación |
309311
| Gráficos | línea temporal de SLA a ancho completo seguida de gráficos de latencia, CPU, memoria e IO; cada servicio persiste su propia ventana temporal y check de latencia; los servicios `no_resident_process` muestran solo SLA porque no tienen runtime de procesos para graficar |
310312
| Procesos | tabla del árbol de procesos detectado a ancho completo, con los procesos hijos marcados en CMD y mantenidos bajo su padre; **Max core** sigue a CPU e informa del uso máximo que ese proceso hizo de un solo core —su hilo más ocupado—, cuyo tooltip indica si el daemon lo midió por hilo o lo acotó con la tasa del proceso; las advertencias de descubrimiento se listan encima, una por línea; se omite cuando `no_resident_process` es true |
313+
| Sesiones SSH | solo en un servicio SSH atribuido: usuario, terminal, PID de sesión y tiempo inactivo en vivo. Los administradores pueden confirmar **close** cuando exista un límite de sesión verificado; los invitados no tienen acción y una sesión no verificable se muestra pero no se puede cerrar |
311314
| Checks | checks configurados y resultado actual; la columna SLA lleva la misma banda de disponibilidad que dibuja la línea temporal de SLA de Gráficas, sobre la ventana en la que esté el selector de esa sección, así que un tramo sin observar se ve como hueco rayado en ambas en vez de como un porcentaje plano en una |
312315
| Locks con nombre | estado de los locks de runtime |
313316
| Reglas | estado de las reglas de remediación/alerta |

‎docs/webui-representation.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ with an `{"ok": bool, "message": string}` body for a handled action.
150150
| --- | --- | --- |
151151
| Service action | `POST /api/services/{name}/{action}[?no_cascade=1]` | `monitor`, `unmonitor`, `start`, `stop`, `restart`, `reload`, `resume`; `reload` is offered only when the service reports `can_reload` from init backend reload support or a valid `reload:` fallback; `no_cascade` skips `also_apply` targets on start/stop/restart |
152152
| Service preflight | `POST /api/services/{name}/preflight` | run preflight checks without changing service state |
153+
| Close SSH session | `POST /api/services/{name}/sessions/{pid}/close?start_ticks=TICKS&terminal=PTS` | admin-only, confirmation-required graceful close of one displayed SSH terminal; the backend re-discovers the terminal plus exact configured `sshd` executable and real user, then requires the same PID and start ticks before sending only `SIGTERM` |
153154
| Watch action | `POST /api/watches/{name}/{action}` | `monitor`, `unmonitor`, `expand`, `probe` (one manual sample), plus RAID `pause`/`resume`, which run a check-and-verify operation and require the `X-Sermo-Confirm` header |
154155
| Notifier test | `POST /api/notifiers/{name}/test` | sends one test notification through the named notifier after confirmation |
155156
| Mount action | `POST /api/mounts/{name}/{action}[?force=1&lazy=1&kill=1]` | `mount`, `umount`, `alert`; `force=1` allows `umount -f`, `lazy=1` allows `umount -l` as the last fallback, and `kill=1` enables `kill_only_if`-gated blocker signalling for `umount`; `/` rejects unmount paths |
@@ -170,6 +171,7 @@ with an `{"ok": bool, "message": string}` body for a handled action.
170171
| Refresh | select with refresh interval, manual refresh button |
171172
| Notifications | opt-in browser-notification bell; once granted, targets that newly start failing raise one grouped notification while the tab is hidden |
172173
| Status | last complete refresh age, connection errors, or panels retaining older data after a partial refresh; `#statusbar` ends with host `uptime:` then daemon `status:` (`ok` / `starting` / …) as a paired tail |
174+
| Sessions | when a configured SSH service can be attributed safely, `sessions (console/SSH): X/Z` is the number of logged-in local-console and SSH terminals; it replaces the former distinct-user count, so three terminals of the same account read `0/3`, not `1` |
173175
| System status | host identity, host type, daemon/backend/runtime summary |
174176
| Browser tab title | after the first full load: `Sermo - <host>` when healthy, `(N) Sermo - <host>` when attention has N signals, `Sermo - <host> · starting` while the daemon is starting; `<host>` is the short hostname from `GET /api/daemon` (same identity as the Basic auth realm) |
175177

@@ -304,6 +306,7 @@ Shared by the Services, Containers and Virtual machines panels.
304306
| General data | an unheaded grid, first area of the expansion: name, state, category, unit/backend, uptime, interval, policy, locks, last event, next remediation, remediation state and process totals; while the row badge is `starting`, expansion may still show the raw init backend (`inactive`) and in-flight check samples from the observe-only cycle |
305307
| Graphs | full-width SLA timeline followed by latency, CPU, memory and IO charts; each service persists its own time window and latency check; `no_resident_process` services show only SLA because they have no process runtime to chart |
306308
| Processes | full-width detected process tree table, with child processes marked in CMD and kept under their parent; **Max core** follows CPU and reports the most a single core was used by that process — its busiest thread — whose tooltip says whether the daemon measured it per thread or bounded it by the process rate; discovery warnings are listed above it, one per line; omitted when `no_resident_process` is true |
309+
| SSH sessions | only on an attributed SSH service: live user, terminal, session PID and idle time. Admins can confirm **close** for a session with a verified boundary; guests have no action and an unverifiable session is displayed but cannot be closed |
307310
| Checks | configured checks and current result; the SLA column carries the same availability band the Graphs SLA timeline draws, on the window that section's selector is on, so an unobserved stretch reads as a hatched gap in both instead of a flat percentage in one |
308311
| Named locks | runtime lock state |
309312
| Rules | remediation/alert rule state |

‎internal/app/daemon.go‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,16 @@ type Deps struct {
212212
// PID, cutting discovery from O(services × processes) to O(processes).
213213
// Optional: nil makes each discoverer read /proc directly.
214214
ProcReader process.Reader
215-
// SSHIdleSampler observes interactive SSH terminals. It owns a separate
216-
// terminal-aware procfs cache because ordinary service discovery deliberately
217-
// avoids reading tty_nr for every process. Optional: nil lets checks build a
218-
// one-shot native sampler.
219-
SSHIdleSampler checks.SSHIdleSamplerFunc
215+
// SSHIdleSampler observes idle interactive SSH terminals. SSHSessionSampler
216+
// presents the same terminal-aware evidence in the web UI and verifies a
217+
// requested close. Both share a separate procfs cache because ordinary
218+
// service discovery deliberately avoids reading tty_nr for every process.
219+
// Optional nil values let checks build one-shot native samplers.
220+
SSHIdleSampler checks.SSHIdleSamplerFunc
221+
SSHSessionSampler checks.SSHSessionSamplerFunc
222+
// SSHSessionVerifier must obtain a fresh terminal/process snapshot for a
223+
// close action. Optional nil uses an uncached native sampler.
224+
SSHSessionVerifier checks.SSHSessionSamplerFunc
220225
// StorageUsage reports filesystem usage for storage checks.
221226
// Optional: nil uses statfs.
222227
StorageUsage checks.StorageUsageFunc
@@ -345,6 +350,9 @@ type Deps struct {
345350
// MountSignaler sends TERM/KILL during policy-gated web umount escalation.
346351
// Optional: nil uses process.OSSignaler.
347352
MountSignaler process.Signaler
353+
// SSHSessionSignaler sends the single SIGTERM used to close a freshly
354+
// revalidated interactive SSH session. Optional: nil uses process.OSSignaler.
355+
SSHSessionSignaler process.Signaler
348356
// MountUserAlerter sends a console alert to users blocking a web mount
349357
// operation. Optional: nil uses the native tty notifier.
350358
MountUserAlerter MountUserAlerter

‎internal/app/monitor.go‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,13 @@ func (m *Monitor) applyConfig(cfg *config.Config) {
231231
// Recreate the shared /proc reader so reloads can change user/group lookup
232232
// policy as well as the freshness window.
233233
m.deps.ProcReader = process.NewCachingReader(process.OSReader{LookupUserName: m.deps.UserLookup.Username}, m.deps.SystemFreshness)
234-
m.deps.SSHIdleSampler = checks.NewSSHIdleSampler(
235-
process.NewCachingReader(process.OSReader{
236-
LookupUserName: m.deps.UserLookup.Username,
237-
LookupGroupName: m.deps.UserLookup.GroupName,
238-
ReadTTY: true,
239-
}, m.deps.SystemFreshness),
240-
m.deps.UserLookup,
241-
)
234+
terminalReader := process.NewCachingReader(process.OSReader{
235+
LookupUserName: m.deps.UserLookup.Username,
236+
LookupGroupName: m.deps.UserLookup.GroupName,
237+
ReadTTY: true,
238+
}, m.deps.SystemFreshness)
239+
m.deps.SSHIdleSampler = checks.NewSSHIdleSampler(terminalReader, m.deps.UserLookup)
240+
m.deps.SSHSessionSampler = checks.NewSSHSessionSampler(terminalReader, m.deps.UserLookup)
242241
notifiers, warns := notify.Build(cfg.Notifiers(), notify.WithTemplateDir(cfg.Global.TemplateDir()))
243242
m.deps.Notifiers = notifiers
244243
m.deps.GlobalNotify = config.NotifyDefault(cfg.Global.Raw)

‎internal/app/webbackend.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type webEntry struct {
8585
discoverer process.Discoverer
8686
selectors []process.Selector
8787
processWarnings []string
88+
sshSessionFilters []process.IdentityFilter
8889
noResidentProcess bool
8990
alsoApply []string
9091
canReload bool
@@ -175,6 +176,7 @@ type WebBackend struct {
175176
mountUsers func(string) ([]process.Process, error)
176177
mountSignaler process.Signaler
177178
mountAlerter MountUserAlerter
179+
sshSessionSampler checks.SSHSessionSamplerFunc
178180
emit func(Event)
179181
defaultTimeout time.Duration
180182
operationTimeout time.Duration
@@ -196,6 +198,9 @@ type WebBackend struct {
196198

197199
mountOperationsMu sync.Mutex
198200
mountOperations map[string]web.MountOperation
201+
202+
sshSessionsMu sync.Mutex
203+
sshSessionCache map[string]cachedSSHSessions
199204
}
200205

201206
func (b *WebBackend) webNow() time.Time {
@@ -269,6 +274,7 @@ func NewWebBackend(ctx context.Context, cfg *config.Config, deps Deps) (*WebBack
269274
mountUsers: deps.MountDiscoverUsers,
270275
mountSignaler: deps.MountSignaler,
271276
mountAlerter: deps.MountUserAlerter,
277+
sshSessionSampler: deps.SSHSessionSampler,
272278
emit: deps.Emit,
273279
defaultTimeout: deps.DefaultTimeout,
274280
operationTimeout: deps.OperationTimeout,
@@ -365,6 +371,12 @@ func attachServiceRuntime(ctx context.Context, entry *webEntry, name string, tre
365371
entry.discoverer = discoverer
366372
entry.selectors = selectors
367373
entry.processWarnings = processWarnings
374+
entry.sshSessionFilters = sshSessionFilters(tree, selectors)
375+
if len(entry.sshSessionFilters) > 0 {
376+
engine.SessionVerifier = freshSSHSessionVerifier(deps, entry.sshSessionFilters)
377+
engine.SessionSignaler = deps.SSHSessionSignaler
378+
entry.engine = engine
379+
}
368380
reloadCtx, cancel := context.WithTimeout(ctx, serviceInitQueryTimeout)
369381
canReload, reloadErr := operation.ReloadSupported(reloadCtx, tree, target.Manager, target.Unit)
370382
cancel()

‎internal/app/webbackend_daemon.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import (
1515
func (b *WebBackend) DaemonInfo(_ context.Context) web.DaemonInfo {
1616
info := web.DaemonInfo{}
1717
info.ActiveUsers = notify.ActiveUserCount()
18+
if sample, err := b.sshSessions(b.allSSHSessionFilters()); err == nil {
19+
info.Sessions = &web.SessionSummary{Console: sample.Console, SSH: len(sample.SSH)}
20+
}
1821

1922
// Short host identity — same source as ${hostname} and the Basic auth realm.
2023
info.Hostname = config.ShortHostname()

‎internal/app/webbackend_services.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,15 @@ func (b *WebBackend) Detail(ctx context.Context, name string) (web.Detail, bool)
472472
d.Processes, d.ProcessTotals = aggregateProcesses(procs, b.runtimeMetricReader())
473473
attachLiveCPU(&d, b.live, name)
474474
}
475+
if len(e.sshSessionFilters) > 0 {
476+
d.SSHSessionsSupported = true
477+
sessions, err := b.sshSessions(e.sshSessionFilters)
478+
if err != nil {
479+
d.ProcessWarnings = append(d.ProcessWarnings, sshSessionUnavailablePrefix+err.Error())
480+
} else {
481+
d.SSHSessions = sshSessionsToWeb(sessions)
482+
}
483+
}
475484

476485
if b.remediation != nil {
477486
if rep, ok := b.remediation.Get(name); ok {

0 commit comments

Comments
 (0)