From e4b0b94664f1229c9994b497e85df0a8efa9ff42 Mon Sep 17 00:00:00 2001 From: INODE64 Date: Sun, 23 Aug 2026 13:39:33 +0200 Subject: [PATCH 01/10] feat(sessions): close managed unavailable SSH logins Objective: Show session PIDs in a dedicated dashboard column and allow exact systemd-logind closure for eligible unavailable SSH attribution rows. Invariant: Unverifiable SSH ancestry never authorizes direct PID signalling; login1 must revalidate process generation, session ID, leader, TTY, remote state and sshd service. Evidence: make check; 116 Playwright desktop/mobile tests; targeted utmp, login1, operation, API and session inventory tests. Limitations: Managed unavailable-session closure requires systemd and a live remote utmp leader with readable process start ticks. --- docs/configuration.es.md | 11 +- docs/configuration.md | 11 +- docs/rules.es.md | 5 +- docs/rules.md | 6 +- docs/safety.es.md | 6 +- docs/safety.md | 6 +- docs/webui-representation.es.md | 9 +- docs/webui-representation.md | 13 +- internal/app/daemon.go | 3 + internal/app/webbackend.go | 12 ++ internal/app/webbackend_ssh_sessions.go | 14 +- internal/app/webbackend_terminal_sessions.go | 3 + internal/app/webbackend_test.go | 7 +- internal/checks/sshidle.go | 40 ++-- internal/checks/sshidle_test.go | 6 +- internal/logind/logind.go | 208 +++++++++++++++++++ internal/logind/logind_test.go | 109 ++++++++++ internal/operation/engine.go | 43 +++- internal/operation/engine_test.go | 40 ++++ internal/utmp/utmp.go | 7 +- internal/utmp/utmp_linux.go | 14 +- internal/utmp/utmp_linux_test.go | 11 +- internal/web/index.html | 208 ++++++++++--------- internal/web/server.go | 3 + internal/web/server_test.go | 9 + internal/web/sessions.go | 52 +++-- internal/web/src/api.js | 4 +- internal/web/src/app.js | 35 ++-- internal/web/src/index.html | 1 + internal/web/src/styles.css | 9 +- tests/web/dashboard.spec.js | 38 +++- 31 files changed, 722 insertions(+), 221 deletions(-) create mode 100644 internal/logind/logind.go create mode 100644 internal/logind/logind_test.go diff --git a/docs/configuration.es.md b/docs/configuration.es.md index 90639417b..cd00f8a47 100644 --- a/docs/configuration.es.md +++ b/docs/configuration.es.md @@ -1116,10 +1116,13 @@ Endpoints de solo lectura: - `GET /api/sessions` — sesiones SSH, tmux y screen actuales y estado de cada origen configurado. Un origen SSH `partial` conserva cada sesión verificada exactamente e informa cada terminal cuya ascendencia `sshd` no puede verificar - como incidencia no disponible sin acción de cierre; nunca lo suma como consola - local. Las filas exponen inactividad y, cuando son atribuibles, CPU, memoria - residente y tasas de IO del árbol de procesos; el inventario tmux y screen - sigue procediendo de muestras publicadas, no de ejecutar un cliente al leer HTTP. + como incidencia no disponible; nunca lo suma como consola local. En systemd, + una incidencia remota con líder utmp vivo puede cerrarse mediante login1 tras + revalidar la sesión exacta; no se señaliza ningún PID incierto. Las filas + exponen el PID separado del texto de sesión y, cuando son atribuibles, + inactividad, CPU, memoria residente y tasas de IO del árbol de procesos; el + inventario tmux y screen sigue procediendo de muestras publicadas, no de + ejecutar un cliente al leer HTTP. - `GET /api/services/{name}/sla?since=24h` — historial de disponibilidad a la resolución a la que esa ventana está almacenada (ver [Resolución del historial almacenado](#resolución-del-historial-almacenado)); `since` es una duración, por diff --git a/docs/configuration.md b/docs/configuration.md index f61a1c957..2423f4379 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1075,10 +1075,13 @@ Read-only endpoints: - `GET /api/sessions` — current SSH, tmux and screen sessions plus the state of each configured session source. A `partial` SSH source keeps every exactly verified session and reports each terminal whose `sshd` ancestry cannot be - verified as an unavailable issue without a close action; it never turns that - terminal into a local-console count. Rows expose idle time plus process-tree - CPU, resident memory and IO rates when attributable; tmux and screen inventory - still comes from published check samples rather than an HTTP-time client run. + verified as an unavailable issue; it never turns that terminal into a + local-console count. On systemd, a remote issue with a live utmp leader may be + closed through login1 after exact session revalidation; no uncertain PID is + signalled. Rows expose PID separately from the session text, plus idle time + and process-tree CPU, resident memory and IO rates when attributable; tmux and + screen inventory still comes from published check samples rather than an + HTTP-time client run. - `GET /api/services/{name}/sla?since=24h` — availability history at the resolution that window is stored at (see [Stored history resolution](#stored-history-resolution)); `since` is a duration, default 24h, diff --git a/docs/rules.es.md b/docs/rules.es.md index 4cf1c3d74..06588c191 100644 --- a/docs/rules.es.md +++ b/docs/rules.es.md @@ -737,7 +737,10 @@ protegidas. Este comportamiento de cierre seguro del check es independiente del inventario del panel. El panel puede mostrar un origen `partial`: conserva las filas SSH verificadas exactamente y expone cada terminal no verificable como una incidencia -no disponible y sin acción. Ese terminal no se cuenta como sesión de consola local. +no disponible. En systemd, una incidencia remota con líder utmp vivo solo puede +cerrarse mediante la identidad de sesión revalidada independientemente por +login1; Sermo nunca señaliza directamente ese PID incierto. Ese terminal no se +cuenta como sesión de consola local. ### Sesiones de terminal (`terminal_sessions`) diff --git a/docs/rules.md b/docs/rules.md index a6d0eb4a3..ad87fe405 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -724,8 +724,10 @@ assuming that no session is protected. This fail-closed check behavior is independent from the dashboard inventory. The dashboard may show a source as `partial`, retaining exactly verified SSH -rows while exposing each unverifiable terminal as an unavailable, non-actionable -issue. Such a terminal is not counted as a local console session. +rows while exposing each unverifiable terminal as an unavailable issue. On +systemd, a remote issue with a live utmp leader can be closed only through +login1's independently revalidated session identity; Sermo never signals that +uncertain PID directly. Such a terminal is not counted as a local console session. ### Terminal sessions (`terminal_sessions`) diff --git a/docs/safety.es.md b/docs/safety.es.md index 5d21b132a..72c044b80 100644 --- a/docs/safety.es.md +++ b/docs/safety.es.md @@ -111,7 +111,11 @@ terminal, PID de sesión y ticks de inicio del proceso. Si falta esa frontera, e terminal cambió o el PID se recicló, se rechaza. Un cierre correcto envía un único `SIGTERM` al proceso de sesión; nunca escala a `SIGKILL`. Un terminal SSH cuya ascendencia no puede verificarse sigue visible como -incidencia no disponible, pero no ofrece acción de cierre ni aporta PID a la API. +incidencia no disponible. En systemd, una incidencia remota con un líder utmp +vivo también expone su PID y un cierre gestionado por login1. Esa vía no envía +señales al proceso incierto: justo antes de `TerminateSession` exige los ticks +de inicio sin cambios y un ID de sesión login1, PID líder, terminal, +`Remote=true` y `Service=sshd` exactos. Las demás incidencias siguen sin acción. El check `terminal_sessions` es de solo observación: ejecuta una lista limitada por argv de `tmux` o `screen` como la cuenta configurada explícitamente y no diff --git a/docs/safety.md b/docs/safety.md index a49b16901..f85a71d81 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -108,7 +108,11 @@ Any missing boundary, changed terminal or recycled PID is rejected. A successful close sends one `SIGTERM` to the per-session process; it never escalates to `SIGKILL`. An SSH terminal whose ancestry cannot be verified remains visible as an -unavailable issue, but has no close action and contributes no PID to the API. +unavailable issue. On systemd, a remote issue with a live utmp leader also +exposes its PID and a login1-managed close. That path sends no signal to the +uncertain process: immediately before `TerminateSession`, it requires unchanged +process start ticks plus an exact login1 session ID, leader PID, terminal, +`Remote=true` and `Service=sshd`. Other unavailable issues remain non-actionable. The `terminal_sessions` check is observation-only. It runs a bounded, argv-only `tmux` or `screen` listing as the explicitly configured account; diff --git a/docs/webui-representation.es.md b/docs/webui-representation.es.md index 0cd6f7e49..217b21e35 100644 --- a/docs/webui-representation.es.md +++ b/docs/webui-representation.es.md @@ -80,7 +80,7 @@ deterministas de la API. | Flujo de cambios | `GET /api/stream` | canal Server-Sent Events que empuja una señal `change` sin payload con cada evento del daemon; el dashboard refresca de inmediato. Solo añade refrescos: el sondeo programado mantiene siempre la cadencia elegida en la barra superior, porque nada se empuja cuando cambia una muestra de métricas y las lecturas de host, servicios y watches dependen de ese sondeo | | Disponibilidad | `GET /readyz?verbose` | `status:` del daemon en la barra superior (`starting` / `ok` / …) | | Servicios | `GET /api/services` | servicios de runtime configurados cargados por sermod (no el inventario de catálogo de `sermoctl services`); `status_observed_at` identifica la muestra real de estado de init que hay detrás de una fila cacheada; `operation_active` es true mientras el motor mantiene el lock de operación del servicio, de modo que una acción lanzada desde cualquier cliente, `sermoctl` o la remediación automática se ve en curso y sus botones de acción siguen deshabilitados | -| Sesiones | `GET /api/sessions` | inventario global de SSH, tmux y screen; cada origen configurado presente informa `available`, `partial`, `collecting` o `unavailable`; un origen SSH parcial incluye sus sesiones verificadas y filas de incidencia no disponibles, sin acción, para los terminales que no pudieron atribuirse de forma segura; un servidor tmux disponible sin sesiones aparece como `empty`, mientras que un espacio tmux/screen ausente se omite; SSH usa la caché breve compartida del muestreador y tmux/screen solo leen muestras de `terminal_sessions` publicadas por el daemon | +| Sesiones | `GET /api/sessions` | inventario global de SSH, tmux y screen; cada origen configurado presente informa `available`, `partial`, `collecting` o `unavailable`; un origen SSH parcial incluye sesiones verificadas y filas de incidencia no disponibles para terminales que no pudieron atribuirse con seguridad; en systemd, una incidencia remota con líder utmp vivo puede ofrecer un cierre gestionado por login1; un servidor tmux disponible sin sesiones aparece como `empty`, mientras que un espacio tmux/screen ausente se omite; SSH usa la caché breve compartida del muestreador y tmux/screen solo leen muestras de `terminal_sessions` publicadas por el daemon | | Expansión de servicio | `GET /api/services/{name}` | checks, información del proceso, reglas | | Métricas de check del servicio | `GET /api/services/{name}/metrics?check=NAME[&metric=KEY]` | el detalle muestra la latencia cuando se omite `metric` y un gráfico por cada métrica numérica con nombre publicada por una comprobación | | Métricas de runtime del servicio | `GET /api/services/{name}/runtime` | historial persistido de CPU/memoria/IO del servicio, de solo lectura y muestreado exclusivamente por ciclos del worker; `current` es la última muestra publicada y las lecturas del panel nunca repiten el descubrimiento de procesos | @@ -179,7 +179,7 @@ con un cuerpo `{"ok": bool, "message": string}` para una acción atendida. | --- | --- | --- | | Acción de servicio | `POST /api/services/{name}/{action}[?no_cascade=1]` | `monitor`, `unmonitor`, `start`, `stop`, `restart`, `reload`, `resume`, `repair`; `restart` es la acción principal para servicios failed/inactive, mientras `repair` es una alternativa secundaria solo manual que usa la recuperación segura de pidfile obsoleto y estado fallido de init antes de arrancar; `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 | | Preflight de servicio | `POST /api/services/{name}/preflight` | ejecuta las comprobaciones de preflight sin cambiar el estado del servicio | -| 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 verificado; las filas de incidencia no disponibles nunca ofrecen esta acción; 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` | +| Cerrar sesión SSH | `POST /api/services/{name}/sessions/{pid}/close?start_ticks=TICKS&terminal=PTS[&managed_by_logind=true]` | solo admin y con confirmación. Las filas SSH verificadas redescubren el ejecutable `sshd` configurado exacto, usuario real, terminal, PID y ticks de inicio antes de un único `SIGTERM`. Una fila remota no disponible, solo en systemd, puede pedir la variante gestionada, que revalida la generación del proceso y el ID, PID líder, terminal, `Remote=true` y `Service=sshd` exactos de login1 antes de `TerminateSession`; nunca señaliza el PID incierto | | Cerrar sesión de terminal | `POST /api/services/{name}/terminal-sessions/{check}/close?multiplexer=TYPE&session=NAME&user=USER&identity=IDENTITY` | solo admin y con confirmación: cierre de una sesión tmux/screen; el backend vuelve a listar el espacio configurado de usuario/socket, exige la misma identidad de generación y ejecuta únicamente el argv exacto de cierre del cliente | | Cerrar servidor tmux vacío | `POST /api/services/{name}/terminal-sessions/{check}/close-empty` | solo admin y con confirmación; solo aparece para un origen tmux presente, vacío y con socket explícito configurado. El backend confirma que sigue vacío, ejecuta el argv exacto `kill-server` de tmux, verifica que el espacio desapareció y elimina solo el socket huérfano sin cambios que pueda quedar | | Botón de operador | `POST /api/services/{name}/button/{button}` | solo-admin, tras confirmación; ejecuta el comando `buttons:` configurado del servicio exactamente como está escrito, acotado por su timeout, y registra un evento de acción con el resultado | @@ -357,7 +357,8 @@ El panel superior Sessions combina terminales SSH interactivas con los espacios de nombres configurados de tmux y GNU screen. La búsqueda cubre tipo, servicio, usuario y sesión; los botones de tipo seleccionan SSH, tmux o screen. La tabla muestra solo el usuario en la columna User y permite ordenar por tipo, usuario, -sesión, estado, idle, CPU, memoria o IO. El filtro de un tipo se oculta cuando no +sesión, PID, estado, idle, CPU, memoria o IO. El PID ocupa su propia columna y +no forma parte del texto de Sesión. El filtro de un tipo se oculta cuando no hay sesiones activas de ese tipo, con el mismo comportamiento de filtros con recuento que los demás paneles. Un origen sin sesiones solo gana fila cuando tiene algo que decir: la espera de muestra y los errores de muestreo se @@ -368,7 +369,7 @@ conectado — el de ssh ante todo — no dice nada y no renderiza fila; aparece cuanto publique una sesión activa. Las filas atribuibles muestran idle y CPU, memoria residente e IO de lectura/escritura del árbol de procesos. Un administrador solo puede confirmar un cierre cuando el backend vuelve a validar -la identidad exacta de la sesión SSH o del multiplexor. +la identidad exacta de la sesión SSH, de systemd-login1 o del multiplexor. Las expansiones abiertas de servicio obtienen y renderizan por completo detalle fresco una vez por refresco del dashboard; las subpeticiones de SLA, métricas, diff --git a/docs/webui-representation.md b/docs/webui-representation.md index 0fdf27e96..607c97cd6 100644 --- a/docs/webui-representation.md +++ b/docs/webui-representation.md @@ -74,7 +74,7 @@ overflow and axe WCAG 2.2 AA rules against deterministic API fixtures. | Change stream | `GET /api/stream` | Server-Sent Events channel that pushes a payload-free `change` signal on every daemon event; the dashboard refetches immediately. It only adds refreshes: the scheduled poll always keeps the cadence chosen in the top bar, because nothing is pushed when a metric sample changes and host/service/watch readings depend on that poll | | Readiness | `GET /readyz?verbose` | daemon `status:` in the top bar (`starting` / `ok` / …) | | Services | `GET /api/services` | configured runtime services loaded by sermod (not `sermoctl services` catalog inventory); `status_observed_at` identifies the real init-status sample behind a cached row; `operation_active` is true while the engine holds the service's operation lock, so an action started from any client, `sermoctl` or automatic remediation shows as in progress and its action buttons stay disabled | -| Sessions | `GET /api/sessions` | dashboard-wide SSH, tmux and screen inventory; each present configured source reports `available`, `partial`, `collecting` or `unavailable`; a partial SSH source includes its verified sessions plus unavailable issue rows for terminals that could not be attributed safely, with no action; an available tmux server with zero sessions is `empty`, while an absent tmux/screen namespace is omitted; SSH uses the shared short-lived sampler cache, while tmux/screen rows come only from daemon-published `terminal_sessions` samples | +| Sessions | `GET /api/sessions` | dashboard-wide SSH, tmux and screen inventory; each present configured source reports `available`, `partial`, `collecting` or `unavailable`; a partial SSH source includes verified sessions plus unavailable issue rows for terminals that could not be attributed safely; on systemd, a remote issue with a live utmp leader may expose a login1-managed close; an available tmux server with zero sessions is `empty`, while an absent tmux/screen namespace is omitted; SSH uses the shared short-lived sampler cache, while tmux/screen rows come only from daemon-published `terminal_sessions` samples | | Service expansion | `GET /api/services/{name}` | checks, process info, rules | | Service check metrics | `GET /api/services/{name}/metrics?check=NAME[&metric=KEY]` | the detail renders latency when `metric` is omitted and one graph for every named numeric metric published by a check | | Service runtime metrics | `GET /api/services/{name}/runtime` | read-only persisted service CPU/memory/IO history sampled exclusively by worker cycles; `current` is the latest published sample and dashboard reads never repeat process discovery | @@ -167,7 +167,7 @@ with an `{"ok": bool, "message": string}` body for a handled action. | --- | --- | --- | | Service action | `POST /api/services/{name}/{action}[?no_cascade=1]` | `monitor`, `unmonitor`, `start`, `stop`, `restart`, `reload`, `resume`, `repair`; `restart` is the primary action for failed/inactive services, while `repair` is a manual-only secondary fallback that uses the guarded stale-pidfile and failed-init-state recovery path before starting; `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 | | Service preflight | `POST /api/services/{name}/preflight` | run preflight checks without changing service state | -| Close SSH session | `POST /api/services/{name}/sessions/{pid}/close?start_ticks=TICKS&terminal=PTS` | admin-only, confirmation-required graceful close of one verified SSH terminal; unavailable issue rows never expose this action; 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` | +| Close SSH session | `POST /api/services/{name}/sessions/{pid}/close?start_ticks=TICKS&terminal=PTS[&managed_by_logind=true]` | admin-only and confirmation-required. Verified SSH rows re-discover the exact configured `sshd` executable, real user, terminal, PID and start ticks before sending one `SIGTERM`. A systemd-only unavailable remote row may instead request the managed variant, which revalidates unchanged process generation plus exact login1 ID, leader PID, terminal, `Remote=true` and `Service=sshd` before `TerminateSession`; it never signals the uncertain PID | | Close terminal session | `POST /api/services/{name}/terminal-sessions/{check}/close?multiplexer=TYPE&session=NAME&user=USER&identity=IDENTITY` | admin-only, confirmation-required close of one tmux/screen session; the backend freshly lists the configured user/socket namespace, requires the same multiplexer generation identity and invokes only the client's exact session-close argv | | Close empty tmux server | `POST /api/services/{name}/terminal-sessions/{check}/close-empty` | admin-only, confirmation-required close available only for a present, empty tmux source with an explicit configured socket; the backend revalidates it is still empty, runs tmux's exact `kill-server` argv, verifies the namespace is gone and removes only the unchanged stale socket it may leave | | Operator button | `POST /api/services/{name}/button/{button}` | admin-only, behind confirmation; runs the service's configured `buttons:` command exactly as written, bounded by its timeout, and records an action event with the outcome | @@ -347,9 +347,10 @@ floating as a total that hides which process it came from. ## Sessions panel The top-level Sessions panel combines interactive SSH terminals with configured -tmux and GNU screen namespaces. Search covers type, service, user and session; +tmux and GNU screen namespaces. Search covers type, service, user, session and PID; type buttons select SSH, tmux or screen. The table shows only the user in its -User column and can sort by type, user, session, state, idle, CPU, memory or IO. +User column, keeps PID in its own column rather than the Session text, and can +sort by type, user, session, PID, state, idle, CPU, memory or IO. A type filter is hidden when that type has no active sessions, using the same counted-filter behavior as the other panels. A sessionless source earns a row only when it has something to say: collecting and sampling failures render with @@ -359,8 +360,8 @@ through the API behind confirmation. An available source with nobody connected — the ssh source above all — says nothing and renders no row; it appears the moment it reports an active session. Attributable rows expose idle time and process-tree CPU, resident memory and read/write IO rates. An admin can confirm -a close only when the backend can freshly revalidate the exact SSH or -multiplexer session identity. +a close only when the backend can freshly revalidate the exact SSH, +systemd-login1 or multiplexer session identity. Open service expansions fetch and fully render fresh detail once per dashboard refresh; SLA, metric, runtime and event subrequests plus open watch/application diff --git a/internal/app/daemon.go b/internal/app/daemon.go index bf16f2fdc..f3b093388 100644 --- a/internal/app/daemon.go +++ b/internal/app/daemon.go @@ -296,6 +296,9 @@ type Deps struct { // SSHSessionSignaler sends the single SIGTERM used to close a freshly // revalidated interactive SSH session. Optional: nil uses process.OSSignaler. SSHSessionSignaler process.Signaler + // ManagedSSHSessionCloser terminates an exact systemd-logind SSH session. + // Optional: nil uses the native login1 D-Bus client on systemd services. + ManagedSSHSessionCloser func(context.Context, operation.SessionTarget) error // MountUserAlerter sends a console alert to users blocking a web mount // operation. Optional: nil uses the native tty notifier. MountUserAlerter MountUserAlerter diff --git a/internal/app/webbackend.go b/internal/app/webbackend.go index 47b5e9a50..2e8ecacd5 100644 --- a/internal/app/webbackend.go +++ b/internal/app/webbackend.go @@ -13,6 +13,7 @@ import ( "sermo/internal/config" "sermo/internal/control" "sermo/internal/execx" + "sermo/internal/logind" "sermo/internal/metrics" "sermo/internal/notify" "sermo/internal/operation" @@ -422,6 +423,17 @@ func attachServiceRuntime(ctx context.Context, entry *webEntry, name string, tre if len(entry.sshSessionFilters) > 0 { engine.SessionVerifier = freshSSHSessionVerifier(deps, entry.sshSessionFilters) engine.SessionSignaler = deps.SSHSessionSignaler + if target.Backend == servicemgr.BackendSystemd { + engine.ManagedSessionCloser = deps.ManagedSSHSessionCloser + if engine.ManagedSessionCloser == nil { + client := logind.NewClient() + engine.ManagedSessionCloser = func(ctx context.Context, target operation.SessionTarget) error { + return client.CloseRemoteSSHSession(ctx, logind.Target{ + PID: target.PID, StartTicks: target.StartTicks, Terminal: target.Terminal, + }) + } + } + } entry.engine = engine } if len(entry.terminalSessions) > 0 { diff --git a/internal/app/webbackend_ssh_sessions.go b/internal/app/webbackend_ssh_sessions.go index 1946c2051..d1c1b8ec0 100644 --- a/internal/app/webbackend_ssh_sessions.go +++ b/internal/app/webbackend_ssh_sessions.go @@ -165,9 +165,10 @@ func sshSessionsToWeb(sample checks.SSHSessionSample) []web.SSHSession { return result } -// CloseSSHSession sends only a graceful SIGTERM through the service operation -// engine. The engine calls its fresh verifier immediately before signalling, so -// this request cannot close a terminal merely because its old PID was reused. +// CloseSSHSession uses the service operation engine. A verified SSH boundary is +// freshly checked before SIGTERM; an unavailable-ancestry row can select only +// the login1 closer, which independently verifies the exact managed session. +// Neither path trusts a displayed PID after it has been reused. func (b *WebBackend) CloseSSHSession(ctx context.Context, name string, session web.SSHSession) web.ActionResult { e := b.entries[name] if e == nil { @@ -180,9 +181,10 @@ func (b *WebBackend) CloseSSHSession(ctx context.Context, name string, session w return b.operateError(name, "close SSH session", serviceSubjectPrefix+name+" "+sshSessionUnsupportedMessage) } r := e.engine.CloseSession(ctx, operation.SessionTarget{ - PID: session.PID, - StartTicks: session.StartTicks, - Terminal: session.Terminal, + PID: session.PID, + StartTicks: session.StartTicks, + Terminal: session.Terminal, + ManagedByLogind: session.ManagedByLogind, }) return webActionResultFrom(r, name, "close SSH session") } diff --git a/internal/app/webbackend_terminal_sessions.go b/internal/app/webbackend_terminal_sessions.go index f8d19acdc..29b63d66b 100644 --- a/internal/app/webbackend_terminal_sessions.go +++ b/internal/app/webbackend_terminal_sessions.go @@ -17,6 +17,7 @@ import ( "sermo/internal/metrics" "sermo/internal/operation" "sermo/internal/process" + "sermo/internal/servicemgr" "sermo/internal/utmp" "sermo/internal/web" ) @@ -260,8 +261,10 @@ func (b *WebBackend) appendSSHSessions(result *web.SessionInventory, seen map[ss source.Message = fmt.Sprintf("%d terminal(s) could not be attributed safely", len(sessions.Issues)) source.Issues = make([]web.SessionIssue, 0, len(sessions.Issues)) for _, issue := range sessions.Issues { + canClose := entry.backend == string(servicemgr.BackendSystemd) && issue.Remote && issue.PID > 0 && issue.StartTicks > 0 source.Issues = append(source.Issues, web.SessionIssue{ User: issue.User, Terminal: issue.Terminal, Message: issue.Message, + PID: issue.PID, StartTicks: issue.StartTicks, CanClose: canClose, ManagedByLogind: canClose, }) } } diff --git a/internal/app/webbackend_test.go b/internal/app/webbackend_test.go index 06742133e..06279028d 100644 --- a/internal/app/webbackend_test.go +++ b/internal/app/webbackend_test.go @@ -213,11 +213,12 @@ func TestWebBackendKeepsVerifiedSSHSessionsWithPartialSource(t *testing.T) { order: []string{"ssh"}, entries: map[string]*webEntry{"ssh": { sshSessionFilters: []process.IdentityFilter{mustWebIdentityFilter(t, "/usr/sbin/sshd", "root")}, + backend: string(servicemgr.BackendSystemd), }}, sshSessionSampler: func(checks.SSHSessionConfig) (checks.SSHSessionSample, error) { return checks.SSHSessionSample{ SSH: []checks.SSHSession{{User: "root", Terminal: "pts/1", PID: 96, StartTicks: 1234}}, - Issues: []checks.SSHSessionIssue{{User: "root", Terminal: "pts/0", Message: "executable /usr/lib/sshd-session was replaced"}}, + Issues: []checks.SSHSessionIssue{{User: "root", Terminal: "pts/0", Message: "executable /usr/lib/sshd-session was replaced", PID: 95, StartTicks: 1200, Remote: true}}, }, nil }, } @@ -226,7 +227,7 @@ func TestWebBackendKeepsVerifiedSSHSessionsWithPartialSource(t *testing.T) { if len(inventory.SSH) != 1 || len(inventory.Sources) != 1 { t.Fatalf("inventory = %+v", inventory) } - if source := inventory.Sources[0]; source.State != web.SessionSourcePartial || len(source.Issues) != 1 || source.Issues[0].Terminal != "pts/0" { + if source := inventory.Sources[0]; source.State != web.SessionSourcePartial || len(source.Issues) != 1 || source.Issues[0].Terminal != "pts/0" || source.Issues[0].PID != 95 || !source.Issues[0].CanClose || !source.Issues[0].ManagedByLogind { t.Fatalf("source = %+v, want partial source with pts/0 issue", source) } if info := b.DaemonInfo(context.Background()); info.Sessions != nil { @@ -266,7 +267,7 @@ func TestWebBackendShowsTerminalSessionsFromPublishedCheckData(t *testing.T) { if len(inventory.Sources) != 2 || len(inventory.Terminal) != 2 { t.Fatalf("session inventory = %+v", inventory) } - if inventory.Terminal[0].Multiplexer != checks.TerminalMultiplexerScreen || inventory.Terminal[0].Name != "120.backup" || inventory.Terminal[1].Windows != 2 { + if inventory.Terminal[0].Multiplexer != checks.TerminalMultiplexerScreen || inventory.Terminal[0].Name != "120.backup" || inventory.Terminal[1].Windows != 2 || !slices.Equal(inventory.Terminal[0].PIDs, []int{120}) || !slices.Equal(inventory.Terminal[1].PIDs, []int{201}) { t.Fatalf("terminal sessions = %+v, want sorted published sessions", inventory.Terminal) } if !inventory.Terminal[0].CanClose || !inventory.Terminal[1].CanClose || !inventory.Terminal[1].HasIdle || inventory.Terminal[1].IdleSeconds != 60 { diff --git a/internal/checks/sshidle.go b/internal/checks/sshidle.go index c440ad1e2..015e5e83e 100644 --- a/internal/checks/sshidle.go +++ b/internal/checks/sshidle.go @@ -66,13 +66,18 @@ type SSHSession struct { Idle time.Duration } -// SSHSessionIssue is one login terminal that the inventory could not attribute -// to a configured sshd identity safely. It is display-only and never carries a -// PID or start time that could authorize a close action. +// SSHSessionIssue is one remote login terminal that the inventory could not +// attribute to a configured sshd identity safely. PID and StartTicks describe +// its utmp leader only when that exact process generation is visible; they +// never authorize direct signalling and may only be used for independent +// session-manager verification. type SSHSessionIssue struct { - User string - Terminal string - Message string + User string + Terminal string + Message string + PID int + StartTicks uint64 + Remote bool } // SSHSessionSample separates local console terminals from interactive SSH @@ -338,30 +343,30 @@ func sampleSSHSessions(sessions []utmp.Session, snapshot map[int]process.Identit seen[session.Line] = true info, err := terminal(session.Line) if err != nil { - addSSHSessionIssue(&sample, session, fmt.Sprintf("terminal metadata unavailable: %v", err)) + addSSHSessionIssue(&sample, session, snapshot, fmt.Sprintf("terminal metadata unavailable: %v", err)) continue } if info.Device == 0 { - addSSHSessionIssue(&sample, session, "terminal has no device identity") + addSSHSessionIssue(&sample, session, snapshot, "terminal has no device identity") continue } processes := terminalProcesses(snapshot, info.Device) if len(processes) == 0 { - addSSHSessionIssue(&sample, session, "terminal has no visible processes") + addSSHSessionIssue(&sample, session, snapshot, "terminal has no visible processes") continue } ssh, target, unknown, err := terminalSSH(processes, snapshot, sshdFilters, resolveUser) if err != nil { - addSSHSessionIssue(&sample, session, fmt.Sprintf("sshd identity verification failed: %v", err)) + addSSHSessionIssue(&sample, session, snapshot, fmt.Sprintf("sshd identity verification failed: %v", err)) continue } if unknown { - addSSHSessionIssue(&sample, session, sshSessionIssueMessage(processes, snapshot)) + addSSHSessionIssue(&sample, session, snapshot, sshSessionIssueMessage(processes, snapshot)) continue } if !ssh { if session.Host != "" { - addSSHSessionIssue(&sample, session, "no configured sshd identity in the live process ancestry") + addSSHSessionIssue(&sample, session, snapshot, "no configured sshd identity in the live process ancestry") continue } sample.Console++ @@ -378,8 +383,15 @@ func sampleSSHSessions(sessions []utmp.Session, snapshot map[int]process.Identit return sample, nil } -func addSSHSessionIssue(s *SSHSessionSample, session utmp.Session, message string) { - s.Issues = append(s.Issues, SSHSessionIssue{User: session.User, Terminal: session.Line, Message: message}) +func addSSHSessionIssue(s *SSHSessionSample, session utmp.Session, snapshot map[int]process.Identity, message string) { + issue := SSHSessionIssue{User: session.User, Terminal: session.Line, Message: message, Remote: session.Host != ""} + if issue.Remote && session.PID > 0 { + if identity, ok := snapshot[session.PID]; ok && identity.StartTicksOK && identity.StartTicks > 0 { + issue.PID = session.PID + issue.StartTicks = identity.StartTicks + } + } + s.Issues = append(s.Issues, issue) } func sshSessionIssueMessage(processes []process.Identity, snapshot map[int]process.Identity) string { diff --git a/internal/checks/sshidle_test.go b/internal/checks/sshidle_test.go index d6d868355..1294c65b9 100644 --- a/internal/checks/sshidle_test.go +++ b/internal/checks/sshidle_test.go @@ -191,7 +191,7 @@ func TestSampleSSHSessionsKeepsVerifiedSessionBesideReplacedBinary(t *testing.T) snapshot := sshSnapshot( process.Identity{PID: sshPrivPID, PPID: sshdPID, Exe: "/usr/lib/sshd-session", ExeOK: true}, process.Identity{PID: sshPeerPID, PPID: sshPrivPID, Exe: "/usr/lib/sshd-session", ExeOK: true, StartTicks: 1234, StartTicksOK: true}, - process.Identity{PID: stalePeerPID, PPID: 1, ExeOK: false, ExePrev: "/usr/lib/sshd-session"}, + process.Identity{PID: stalePeerPID, PPID: 1, ExeOK: false, ExePrev: "/usr/lib/sshd-session", StartTicks: 5678, StartTicksOK: true}, process.Identity{PID: staleShellPID, PPID: stalePeerPID, UID: testUserID, Exe: "/bin/bash", ExeOK: true, TTY: staleTTY, TTYOK: true}, ) snapshot[sshShellPID] = process.Identity{PID: sshShellPID, PPID: sshPeerPID, UID: testUserID, Exe: "/bin/bash", ExeOK: true, TTY: testTTY, TTYOK: true} @@ -207,7 +207,7 @@ func TestSampleSSHSessionsKeepsVerifiedSessionBesideReplacedBinary(t *testing.T) } sample, err := sampleSSHSessions([]utmp.Session{ {User: "root", Line: "pts/0", Host: "192.0.2.10"}, - {User: "root", Line: "pts/1", Host: "192.0.2.11"}, + {PID: stalePeerPID, User: "root", Line: "pts/1", Host: "192.0.2.11"}, }, snapshot, terminal, now, mustSSHDFilters(t), testSSHLookup().ResolveUser) if err != nil { t.Fatal(err) @@ -215,7 +215,7 @@ func TestSampleSSHSessionsKeepsVerifiedSessionBesideReplacedBinary(t *testing.T) if len(sample.SSH) != 1 || sample.SSH[0].Terminal != "pts/0" || len(sample.Issues) != 1 { t.Fatalf("sample = %+v, want verified pts/0 plus unavailable pts/1", sample) } - if got := sample.Issues[0]; got.Terminal != "pts/1" || got.Message != "executable /usr/lib/sshd-session was replaced" { + if got := sample.Issues[0]; got.Terminal != "pts/1" || got.Message != "executable /usr/lib/sshd-session was replaced" || got.PID != stalePeerPID || got.StartTicks != 5678 || !got.Remote { t.Fatalf("issue = %+v", got) } } diff --git a/internal/logind/logind.go b/internal/logind/logind.go new file mode 100644 index 000000000..4ab444712 --- /dev/null +++ b/internal/logind/logind.go @@ -0,0 +1,208 @@ +// Package logind safely identifies and terminates exact systemd login sessions. +package logind + +import ( + "context" + "errors" + "fmt" + "math" + + "github.com/godbus/dbus/v5" + + "sermo/internal/process" +) + +const ( + login1Destination = "org.freedesktop.login1" + login1ManagerPath = dbus.ObjectPath("/org/freedesktop/login1") + login1ManagerInterface = "org.freedesktop.login1.Manager" + login1SessionInterface = "org.freedesktop.login1.Session" + dbusPropertiesGetAll = "org.freedesktop.DBus.Properties.GetAll" + sshdService = "sshd" +) + +// Target is the utmp session leader and process generation displayed to the +// operator. It is evidence to revalidate, never authority to signal the PID. +type Target struct { + PID int + StartTicks uint64 + Terminal string +} + +type session struct { + ID string + TTY string + Service string + Leader int + Remote bool +} + +type sessionBus interface { + SessionByPID(ctx context.Context, pid int) (session, error) + TerminateSession(ctx context.Context, id string) error + Close() error +} + +// Client closes a session only after checking its process generation and all +// safety-significant login1 properties immediately before termination. +type Client struct { + connect func(context.Context) (sessionBus, error) + startTicks func(int) (uint64, bool) +} + +// NewClient returns a native system-bus login1 client. +func NewClient() Client { + return Client{connect: connectSystemBus, startTicks: process.StartTicks} +} + +// CloseRemoteSSHSession asks login1 to terminate one exact remote sshd login. +func (c Client) CloseRemoteSSHSession(ctx context.Context, target Target) error { + if target.PID <= 0 || target.StartTicks == 0 || target.Terminal == "" { + return errors.New("invalid managed SSH session identity") + } + if err := verifyStartTicks(c.startTicks, target); err != nil { + return err + } + bus, err := c.connect(ctx) + if err != nil { + return fmt.Errorf("connect to systemd-logind: %w", err) + } + defer func() { _ = bus.Close() }() + + got, err := bus.SessionByPID(ctx, target.PID) + if err != nil { + return fmt.Errorf("resolve login session for PID %d: %w", target.PID, err) + } + if err := verifySession(got, target); err != nil { + return err + } + if err := verifyStartTicks(c.startTicks, target); err != nil { + return err + } + if err := bus.TerminateSession(ctx, got.ID); err != nil { + return fmt.Errorf("terminate login session %q: %w", got.ID, err) + } + return nil +} + +func verifyStartTicks(read func(int) (uint64, bool), target Target) error { + if read == nil { + return errors.New("process generation verification is unavailable") + } + got, ok := read(target.PID) + if !ok || got != target.StartTicks { + return fmt.Errorf("PID %d process generation changed", target.PID) + } + return nil +} + +func verifySession(got session, target Target) error { + if got.ID == "" { + return errors.New("login session has no stable ID") + } + if got.Leader != target.PID { + return fmt.Errorf("login session leader changed from PID %d to %d", target.PID, got.Leader) + } + if got.TTY != target.Terminal { + return fmt.Errorf("login session terminal changed from %q to %q", target.Terminal, got.TTY) + } + if !got.Remote { + return errors.New("login session is not remote") + } + if got.Service != sshdService { + return fmt.Errorf("login session service is %q, not sshd", got.Service) + } + return nil +} + +type dbusSessionBus struct{ conn *dbus.Conn } + +func connectSystemBus(ctx context.Context) (sessionBus, error) { //nolint:ireturn // the private bus interface keeps safety logic unit-testable without D-Bus + conn, err := dbus.ConnectSystemBus(dbus.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("connect system bus: %w", err) + } + return dbusSessionBus{conn: conn}, nil +} + +func (b dbusSessionBus) SessionByPID(ctx context.Context, pid int) (session, error) { + if pid <= 0 || pid > math.MaxInt32 { + return session{}, fmt.Errorf("invalid login session PID %d", pid) + } + dbusPID := uint32(pid) + manager := b.conn.Object(login1Destination, login1ManagerPath) + var path dbus.ObjectPath + if err := manager.CallWithContext(ctx, login1ManagerInterface+".GetSessionByPID", 0, dbusPID).Store(&path); err != nil { + return session{}, fmt.Errorf("call login1 GetSessionByPID: %w", err) + } + var properties map[string]dbus.Variant + if err := b.conn.Object(login1Destination, path).CallWithContext(ctx, dbusPropertiesGetAll, 0, login1SessionInterface).Store(&properties); err != nil { + return session{}, fmt.Errorf("read login1 session properties: %w", err) + } + return sessionFromProperties(properties) +} + +func (b dbusSessionBus) TerminateSession(ctx context.Context, id string) error { + if err := b.conn.Object(login1Destination, login1ManagerPath). + CallWithContext(ctx, login1ManagerInterface+".TerminateSession", 0, id).Err; err != nil { + return fmt.Errorf("call login1 TerminateSession: %w", err) + } + return nil +} + +func (b dbusSessionBus) Close() error { + if err := b.conn.Close(); err != nil { + return fmt.Errorf("close system bus connection: %w", err) + } + return nil +} + +func sessionFromProperties(properties map[string]dbus.Variant) (session, error) { + var result session + var ok bool + if result.ID, ok = stringProperty(properties, "Id"); !ok { + return session{}, errors.New("login session Id property is unavailable") + } + if result.TTY, ok = stringProperty(properties, "TTY"); !ok { + return session{}, errors.New("login session TTY property is unavailable") + } + if result.Service, ok = stringProperty(properties, "Service"); !ok { + return session{}, errors.New("login session Service property is unavailable") + } + leader, ok := uint32Property(properties, "Leader") + if !ok || leader > math.MaxInt32 { + return session{}, errors.New("login session Leader property is unavailable") + } + result.Leader = int(leader) + if result.Remote, ok = boolProperty(properties, "Remote"); !ok { + return session{}, errors.New("login session Remote property is unavailable") + } + return result, nil +} + +func stringProperty(properties map[string]dbus.Variant, name string) (string, bool) { + variant, ok := properties[name] + if !ok { + return "", false + } + value, ok := variant.Value().(string) + return value, ok +} + +func uint32Property(properties map[string]dbus.Variant, name string) (uint32, bool) { + variant, ok := properties[name] + if !ok { + return 0, false + } + value, ok := variant.Value().(uint32) + return value, ok +} + +func boolProperty(properties map[string]dbus.Variant, name string) (bool, bool) { + variant, ok := properties[name] + if !ok { + return false, false + } + value, ok := variant.Value().(bool) + return value, ok +} diff --git a/internal/logind/logind_test.go b/internal/logind/logind_test.go new file mode 100644 index 000000000..5e2fb7ff9 --- /dev/null +++ b/internal/logind/logind_test.go @@ -0,0 +1,109 @@ +package logind + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/godbus/dbus/v5" +) + +type fakeSessionBus struct { + session session + lookupErr error + terminate string + termErr error + closeCalls int +} + +func (b *fakeSessionBus) SessionByPID(context.Context, int) (session, error) { + return b.session, b.lookupErr +} +func (b *fakeSessionBus) TerminateSession(_ context.Context, id string) error { + b.terminate = id + return b.termErr +} +func (b *fakeSessionBus) Close() error { b.closeCalls++; return nil } + +func testClient(bus *fakeSessionBus, ticks ...uint64) Client { + index := 0 + return Client{ + connect: func(context.Context) (sessionBus, error) { return bus, nil }, + startTicks: func(int) (uint64, bool) { + value := ticks[min(index, len(ticks)-1)] + index++ + return value, true + }, + } +} + +func TestCloseRemoteSSHSessionTerminatesExactLogin(t *testing.T) { + bus := &fakeSessionBus{session: session{ID: "c42", TTY: "pts/11", Service: sshdService, Leader: 96, Remote: true}} + err := testClient(bus, 1234, 1234).CloseRemoteSSHSession(t.Context(), Target{PID: 96, StartTicks: 1234, Terminal: "pts/11"}) + if err != nil { + t.Fatal(err) + } + if bus.terminate != "c42" || bus.closeCalls != 1 { + t.Fatalf("terminated=%q close calls=%d", bus.terminate, bus.closeCalls) + } +} + +func TestCloseRemoteSSHSessionRejectsChangedIdentity(t *testing.T) { + base := session{ID: "c42", TTY: "pts/11", Service: sshdService, Leader: 96, Remote: true} + for _, tt := range []struct { + name string + mutate func(*session) + ticks []uint64 + wantErr string + }{ + {name: "leader", mutate: func(s *session) { s.Leader = 97 }, ticks: []uint64{1234, 1234}, wantErr: "leader changed"}, + {name: "terminal", mutate: func(s *session) { s.TTY = "pts/12" }, ticks: []uint64{1234, 1234}, wantErr: "terminal changed"}, + {name: "local", mutate: func(s *session) { s.Remote = false }, ticks: []uint64{1234, 1234}, wantErr: "not remote"}, + {name: "service", mutate: func(s *session) { s.Service = "login" }, ticks: []uint64{1234, 1234}, wantErr: "not sshd"}, + {name: "generation before lookup", ticks: []uint64{9999}, wantErr: "generation changed"}, + {name: "generation after lookup", ticks: []uint64{1234, 9999}, wantErr: "generation changed"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := base + if tt.mutate != nil { + tt.mutate(&got) + } + bus := &fakeSessionBus{session: got} + err := testClient(bus, tt.ticks...).CloseRemoteSSHSession(t.Context(), Target{PID: 96, StartTicks: 1234, Terminal: "pts/11"}) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + if bus.terminate != "" { + t.Fatalf("terminated %q after rejected identity", bus.terminate) + } + }) + } +} + +func TestCloseRemoteSSHSessionReportsTerminateFailure(t *testing.T) { + bus := &fakeSessionBus{ + session: session{ID: "c42", TTY: "pts/11", Service: sshdService, Leader: 96, Remote: true}, + termErr: errors.New("denied"), + } + err := testClient(bus, 1234, 1234).CloseRemoteSSHSession(t.Context(), Target{PID: 96, StartTicks: 1234, Terminal: "pts/11"}) + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("error = %v", err) + } +} + +func TestSessionFromPropertiesRequiresTypedIdentity(t *testing.T) { + properties := map[string]dbus.Variant{ + "Id": dbus.MakeVariant("c42"), "TTY": dbus.MakeVariant("pts/11"), + "Service": dbus.MakeVariant(sshdService), "Leader": dbus.MakeVariant(uint32(96)), + "Remote": dbus.MakeVariant(true), + } + got, err := sessionFromProperties(properties) + if err != nil || got.ID != "c42" || got.Leader != 96 { + t.Fatalf("session=%+v error=%v", got, err) + } + delete(properties, "Leader") + if _, err := sessionFromProperties(properties); err == nil { + t.Fatal("missing Leader property accepted") + } +} diff --git a/internal/operation/engine.go b/internal/operation/engine.go index 228a50bf6..cd94961a4 100644 --- a/internal/operation/engine.go +++ b/internal/operation/engine.go @@ -98,6 +98,9 @@ type Engine struct { // signalling it. Nil means this service does not offer session closing. SessionVerifier func(ctx context.Context, target SessionTarget) error SessionSignaler process.Signaler + // ManagedSessionCloser revalidates and terminates one exact login-manager + // session. It never falls through to direct PID signalling. + ManagedSessionCloser func(ctx context.Context, target SessionTarget) error // TerminalSessionCloser revalidates and closes one tmux/screen session // through its configured client. It remains a manual-only operation. TerminalSessionCloser func(ctx context.Context, target TerminalSessionTarget) error @@ -168,11 +171,13 @@ type plan struct { // SessionTarget is a freshly displayed SSH terminal session. StartTicks binds // its PID to one process generation so a PID that has exited and been reused is -// rejected before it can be signalled. +// rejected before it can be closed. ManagedByLogind selects the independently +// verified systemd-logind path and never authorizes direct signalling. type SessionTarget struct { - PID int - StartTicks uint64 - Terminal string + PID int + StartTicks uint64 + Terminal string + ManagedByLogind bool } // TerminalSessionTarget identifies one exact multiplexer session generation @@ -229,8 +234,9 @@ func (e Engine) Resume(ctx context.Context) Result { // CloseSession gracefully terminates one operator-selected SSH session. It // shares the service operation lock, named locks, guards, timeout and event // path with normal service actions, but deliberately skips service pre/post -// flight because the SSH daemon itself remains running. It sends only SIGTERM; -// there is no escalation to SIGKILL for an interactive user session. +// flight because the SSH daemon itself remains running. Direct process closes +// send only SIGTERM; managed closes use the independently verified login manager. +// Neither path escalates to SIGKILL for an interactive user session. func (e Engine) CloseSession(ctx context.Context, target SessionTarget) Result { return e.run(ctx, plan{action: actionCloseSession, closeSession: &target}) } @@ -618,6 +624,13 @@ func residualsRemain(remaining []process.Process, phase string) string { } func (e Engine) closeSession(ctx context.Context, target SessionTarget, result *Result) bool { + if target.ManagedByLogind { + var closer func(context.Context) error + if e.ManagedSessionCloser != nil { + closer = func(ctx context.Context) error { return e.ManagedSessionCloser(ctx, target) } + } + return runSessionCloser(ctx, result, closer, "managed SSH session close is unavailable for this service", "close SSH session: ") + } if e.SessionVerifier == nil { result.Status = ResultFailed result.Message = "SSH session close is unavailable for this service" @@ -651,19 +664,27 @@ func (e Engine) closeSession(ctx context.Context, target SessionTarget, result * } func (e Engine) closeTerminalSession(ctx context.Context, target TerminalSessionTarget, result *Result) bool { - if e.TerminalSessionCloser == nil { + var closer func(context.Context) error + if e.TerminalSessionCloser != nil { + closer = func(ctx context.Context) error { return e.TerminalSessionCloser(ctx, target) } + } + return runSessionCloser(ctx, result, closer, "terminal session close is unavailable for this service", "close terminal session: ") +} + +func runSessionCloser(ctx context.Context, result *Result, closer func(context.Context) error, unavailable, errorPrefix string) bool { + if closer == nil { result.Status = ResultFailed - result.Message = "terminal session close is unavailable for this service" + result.Message = unavailable return false } if err := ctx.Err(); err != nil { result.Status = ResultFailed - result.Message = "close terminal session: " + err.Error() + result.Message = errorPrefix + err.Error() return false } - if err := e.TerminalSessionCloser(ctx, target); err != nil { + if err := closer(ctx); err != nil { result.Status = ResultFailed - result.Message = "close terminal session: " + err.Error() + result.Message = errorPrefix + err.Error() return false } return true diff --git a/internal/operation/engine_test.go b/internal/operation/engine_test.go index 7f747cc92..0d13f0915 100644 --- a/internal/operation/engine_test.go +++ b/internal/operation/engine_test.go @@ -1717,6 +1717,46 @@ func TestCloseSessionNeverSignalsWhenVerificationRejectsIt(t *testing.T) { } } +func TestCloseManagedSessionUsesManagerWithoutSignalling(t *testing.T) { + h := defaultHarness() + e := h.engine() + signaler := &recordingSignaler{} + e.SessionSignaler = signaler + want := SessionTarget{PID: 96, StartTicks: 1234, Terminal: "pts/11", ManagedByLogind: true} + closed := 0 + e.ManagedSessionCloser = func(_ context.Context, target SessionTarget) error { + closed++ + if target != want { + t.Fatalf("target = %+v, want %+v", target, want) + } + return nil + } + + res := e.CloseSession(context.Background(), want) + if res.Status != ResultOK || closed != 1 { + t.Fatalf("result = %+v closed=%d", res, closed) + } + if len(signaler.calls) != 0 { + t.Fatalf("signals = %v, managed session must not be signalled directly", signaler.calls) + } +} + +func TestCloseManagedSessionFailsClosedWhenManagerRejectsIt(t *testing.T) { + h := defaultHarness() + e := h.engine() + signaler := &recordingSignaler{} + e.SessionSignaler = signaler + e.ManagedSessionCloser = func(context.Context, SessionTarget) error { return errors.New("login session changed") } + + res := e.CloseSession(context.Background(), SessionTarget{PID: 96, StartTicks: 1234, Terminal: "pts/11", ManagedByLogind: true}) + if res.Status != ResultFailed || !strings.Contains(res.Message, "login session changed") { + t.Fatalf("result = %+v", res) + } + if len(signaler.calls) != 0 { + t.Fatalf("signals = %v, want none", signaler.calls) + } +} + func TestCloseTerminalSessionUsesOperationSafetyPath(t *testing.T) { h := defaultHarness() e := h.engine() diff --git a/internal/utmp/utmp.go b/internal/utmp/utmp.go index f2b6028c9..cd53af90b 100644 --- a/internal/utmp/utmp.go +++ b/internal/utmp/utmp.go @@ -4,10 +4,11 @@ // binary record parsing lives in one place. package utmp -// Session is one active login session: the user, terminal line (for example -// "pts/0" or "tty1") and the remote host recorded by login accounting. Host -// is empty for local sessions. +// Session is one active login session: its leader PID, user, terminal line (for +// example "pts/0" or "tty1") and remote host recorded by login accounting. +// Host is empty for local sessions. type Session struct { + PID int User string Line string Host string diff --git a/internal/utmp/utmp_linux.go b/internal/utmp/utmp_linux.go index 88d03d642..818d140d8 100644 --- a/internal/utmp/utmp_linux.go +++ b/internal/utmp/utmp_linux.go @@ -7,17 +7,20 @@ import ( "encoding/binary" "errors" "fmt" + "math" "os" "strings" ) // Linux utmp record layout (struct utmp): a fixed 384-byte record whose first // uint16 is the entry type; USER_PROCESS (7) marks an interactive login. The -// ut_line (terminal) and ut_user (name) fields are fixed-width NUL-padded -// C strings at the offsets below. +// ut_pid leader and the fixed-width NUL-padded terminal/user fields use the +// offsets below. const ( recordSize = 384 userProcess = 7 + pidOffset = 4 + pidSize = 4 lineOffset = 8 lineSize = 32 userOffset = 44 @@ -74,13 +77,18 @@ func parse(data []byte) []Session { if nativeEndian.Uint16(rec[:2]) != userProcess { continue } + pidBits := nativeEndian.Uint32(rec[pidOffset : pidOffset+pidSize]) + pid := 0 + if pidBits <= math.MaxInt32 { + pid = int(pidBits) + } line := cString(rec[lineOffset : lineOffset+lineSize]) user := cString(rec[userOffset : userOffset+userSize]) host := cString(rec[hostOffset : hostOffset+hostSize]) if line == "" || user == "" { continue } - out = append(out, Session{User: user, Line: line, Host: host}) + out = append(out, Session{PID: pid, User: user, Line: line, Host: host}) } return out } diff --git a/internal/utmp/utmp_linux_test.go b/internal/utmp/utmp_linux_test.go index bec817014..1fca54d6c 100644 --- a/internal/utmp/utmp_linux_test.go +++ b/internal/utmp/utmp_linux_test.go @@ -8,9 +8,10 @@ import ( "testing" ) -func record(typ uint16, line, user, host string) []byte { +func record(typ uint16, pid int32, line, user, host string) []byte { rec := make([]byte, recordSize) nativeEndian.PutUint16(rec[:2], typ) + nativeEndian.PutUint32(rec[pidOffset:pidOffset+pidSize], uint32(pid)) copy(rec[lineOffset:lineOffset+lineSize], line) copy(rec[userOffset:userOffset+userSize], user) copy(rec[hostOffset:hostOffset+hostSize], host) @@ -18,14 +19,14 @@ func record(typ uint16, line, user, host string) []byte { } func TestParseKeepsOnlyUserProcesses(t *testing.T) { - data := append(record(userProcess, "pts/0", "root", "192.0.2.10"), record(2, "tty1", "login", "")...) - data = append(data, record(userProcess, "pts/1", "fran", "")...) + data := append(record(userProcess, 4242, "pts/0", "root", "192.0.2.10"), record(2, 1, "tty1", "login", "")...) + data = append(data, record(userProcess, 4343, "pts/1", "fran", "")...) got := parse(data) if len(got) != 2 { t.Fatalf("parse returned %d sessions: %+v", len(got), got) } - if got[0] != (Session{User: "root", Line: "pts/0", Host: "192.0.2.10"}) || got[1] != (Session{User: "fran", Line: "pts/1"}) { + if got[0] != (Session{PID: 4242, User: "root", Line: "pts/0", Host: "192.0.2.10"}) || got[1] != (Session{PID: 4343, User: "fran", Line: "pts/1"}) { t.Fatalf("parse = %+v", got) } } @@ -34,7 +35,7 @@ func TestSessionsFromFallsBackAndReads(t *testing.T) { dir := t.TempDir() missing := filepath.Join(dir, "absent") present := filepath.Join(dir, "utmp") - data := append(record(userProcess, "pts/0", "fran", "192.0.2.10"), record(userProcess, "pts/1", "fran", "192.0.2.11")...) + data := append(record(userProcess, 42, "pts/0", "fran", "192.0.2.10"), record(userProcess, 43, "pts/1", "fran", "192.0.2.11")...) if err := os.WriteFile(present, data, 0o644); err != nil { t.Fatal(err) } diff --git a/internal/web/index.html b/internal/web/index.html index f698f8944..65c367c00 100644 --- a/internal/web/index.html +++ b/internal/web/index.html @@ -6,7 +6,7 @@ Sermo @@ -89,6 +89,7 @@

Sessions

Type User Session + PID State Idle CPU @@ -611,49 +612,49 @@