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..9fddcc687 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, @@ -529,11 +530,11 @@ activity es un evento. | Tipo de check | Columnas específicas | | --- | --- | | `storage` | Name, Usage, Filesystem, Mount point; filtra por filesystem si hay más de uno | -| `file` | Name, Path, edad actual, límite de edad configurado | -| `net` | Name, interfaz, enlace, velocidad, errores | +| `file` | Name, Path, edad, límite de edad configurado; si el check configura `summary`, conserva Age y sustituye el límite por Summary | +| `net` | Name, interfaz, driver, velocidad, direcciones IP asignadas, enlace, errores | | `hdparm` | Name, dispositivo, bus, lectura buffered, lectura cached | | `lvm` | Name, salud, VG, LV, tamaño de VG, libre en VG, motivos | -| `smart` | Name, dispositivo, bus, salud, modelo, número de serie, firmware, WWN, medio, capacidad, temperatura, sectores reasignados/pendientes, errores CRC de enlace y de medio, desgaste, tiempo encendido formateado, ciclos de encendido, último autotest | +| `smart` | Name, dispositivo, interfaz (bus del kernel), salud, temperatura, desgaste, sectores reasignados y tiempo encendido formateado; el desplegable incluye el resto de lecturas de identidad, salud y autotest | | `diskio` | Name, dispositivo, bus, utilización, lectura, escritura, await, leído total, escrito total (acumulados desde el arranque, para distinguir un disco ocioso de uno que nadie toca nunca) | | `cert` | Name, origen, días restantes, caducidad, emisor | | `raid` | Name, array, tamaño, degradado, recuperando | @@ -608,7 +609,7 @@ Expansión de fila: | --- | --- | | Config | tipo, categoría, intervalo, dispara (en fallo / en umbral), estado, flag de monitorización, hook, notifiers, dry run | | Readings | lecturas actuales del host, seguidas de las condiciones y umbrales de la comprobación | -| Graphs | una gráfica por serie numérica que publica la comprobación, dibujada con el panel que recibe la métrica de una comprobación de servicio, sobre su propia ventana `1h/24h/7d/30d/1y`; una métrica de estado se dibuja como banda tipo disponibilidad en vez de línea, limitada a ámbar cuando su gravedad es warning; ausente cuando la comprobación no publica ningún número | +| Graphs | una gráfica por serie numérica que publica la comprobación, dibujada con el panel que recibe la métrica de una comprobación de servicio, sobre su propia ventana `1h/24h/7d/30d/1y`; una métrica de estado se dibuja como banda tipo disponibilidad en vez de línea, limitada a ámbar cuando su gravedad es warning; ausente cuando la comprobación no publica ningún número; SMART reduce la unión de métricas ATA/NVMe a los atributos observados para ese dispositivo (incluidos los últimos atributos conocidos conservados), por lo que un indicador no compatible no crea ni panel ni aviso de falta de datos | | Availability | la sección de SLA del servicio, cuando la comprobación afirma disponibilidad; su ventana es independiente de la de Graphs, porque leen series distintas | | Activity | eventos recientes del watch | | Expand | acción de expansión de almacenamiento cuando está configurada | diff --git a/docs/webui-representation.md b/docs/webui-representation.md index 0fdf27e96..a263c9ab2 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 @@ -515,11 +516,11 @@ latest completed daemon-cycle or manual sample, while Last activity is an event. | Check type | Type-specific columns | | --- | --- | | `storage` | Name, Usage, Filesystem, Mount point; filters by filesystem when more than one is present | -| `file` | Name, Path, current age, configured age limit; a configured check `summary` replaces the age and limit columns with Summary | -| `net` | Name, interface, link, speed, errors | +| `file` | Name, Path, age, configured age limit; a configured check `summary` keeps Age and replaces the limit with Summary | +| `net` | Name, interface, driver, speed, assigned IP addresses, link, errors | | `hdparm` | Name, device, bus, buffered read, cached read | | `lvm` | Name, health, VG, LV, VG size, VG free, reasons | -| `smart` | Name, device, bus, health, model, serial, firmware, WWN, medium, capacity, temperature, reallocated/pending sectors, link CRC and media errors, wear, formatted power-on time, power cycles, last self-test | +| `smart` | Name, device, interface (kernel bus), health, temperature, wear, reallocated sectors, formatted power-on time; the expansion includes the remaining identity, health and self-test readings | | `diskio` | Name, device, bus, utilization, read, write, await, read total, written total (cumulative since boot, so an idle disk is distinguishable from one nothing ever touches) | | `cert` | Name, source, days left, expiry, issuer | | `raid` | Name, array, size, degraded, recovering | @@ -592,7 +593,7 @@ Row expansion: | Config | type, category, interval, fires (on fail / on threshold), state, monitor flag, hook, notifiers, dry run | | Readings | current host readings, then check conditions and thresholds | | Held by | for a host-wide count (`fds`, `pids`), the services that make it up — each with its share, each opening that service — from the per-service figures the service table already carries; the host number says how many, this says who | -| Graphs | one chart per numeric series the check publishes, drawn by the panel a service check's metric gets, on its own `1h/24h/7d/30d/1y` window; a state metric renders as an availability-style band instead of a line chart, amber-capped when its severity is warning; absent when the check publishes no number | +| Graphs | one chart per numeric series the check publishes, drawn by the panel a service check's metric gets, on its own `1h/24h/7d/30d/1y` window; a state metric renders as an availability-style band instead of a line chart, amber-capped when its severity is warning; absent when the check publishes no number; SMART narrows the union of ATA/NVMe metrics to attributes observed for that device (including retained last-known attributes), so unsupported indicators create neither a panel nor an empty-data notice | | Availability | the service SLA section, when the check asserts availability; its window is separate from the Graphs one, because the two read different series | | Activity | recent watch events | | Expand | storage expansion action when configured | diff --git a/internal/app/checkreadings.go b/internal/app/checkreadings.go index ab5773da3..f52a3ac05 100644 --- a/internal/app/checkreadings.go +++ b/internal/app/checkreadings.go @@ -24,6 +24,7 @@ const ( watchReadingProgressDecimals = 1 watchReadingLabelAddresses = "Addresses" + watchReadingLabelAddressCount = "Address count" watchReadingLabelAllocated = "Allocated" watchReadingLabelAge = "Age" watchReadingLabelArrays = "Arrays" @@ -517,6 +518,13 @@ func netCheckReadings(data map[string]any) []web.WatchReading { out = append(out, web.WatchReading{Field: checks.DataKeyInterface, Label: watchReadingLabelInterface, Value: iface}) } out = append(out, netIdentityReadings(data)...) + if addresses := readingStringList(data[checks.DataKeyAddresses]); len(addresses) > 0 { + out = append(out, web.WatchReading{ + Field: checks.DataKeyAddresses, + Label: watchReadingLabelAddresses, + Value: strings.Join(addresses, displayListSeparator), + }) + } metric := cfgval.String(data[checks.DataKeyMetric]) value := cfgval.String(data[checks.DataKeyValue]) switch metric { @@ -538,7 +546,7 @@ func netCheckReadings(data map[string]any) []web.WatchReading { } case checks.NetMetricAddress: if value != "" { - out = append(out, web.WatchReading{Field: checks.NetMetricAddress, Label: watchReadingLabelAddresses, Value: value}) + out = append(out, web.WatchReading{Field: checks.NetMetricAddress, Label: watchReadingLabelAddressCount, Value: value}) } } return out diff --git a/internal/app/checkreadings_test.go b/internal/app/checkreadings_test.go index 20bfd331b..a6e6e2cf1 100644 --- a/internal/app/checkreadings_test.go +++ b/internal/app/checkreadings_test.go @@ -521,3 +521,19 @@ func TestNetCheckReadingsOmitWhatAnInterfaceDoesNotHave(t *testing.T) { t.Errorf("kind = %q, want bridge", kind) } } + +func TestNetCheckReadingsPublishAssignedAddresses(t *testing.T) { + readings := checkReadings(checks.CheckTypeNet, map[string]any{ + checks.DataKeyInterface: "eth0", + checks.DataKeyMetric: checks.NetMetricAddress, + checks.DataKeyValue: 2, + checks.DataKeyAddresses: []string{"192.0.2.10", "2001:db8::10"}, + }) + + if got := readingByField(readings, checks.DataKeyAddresses).Value; got != "192.0.2.10, 2001:db8::10" { + t.Fatalf("assigned addresses = %q, want both IP addresses (%+v)", got, readings) + } + if got := readingByField(readings, checks.NetMetricAddress); got.Value != "2" || got.Label != "Address count" { + t.Fatalf("address count = %+v, want an explicitly labelled count", got) + } +} 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..dfaeac013 100644 --- a/internal/app/webbackend.go +++ b/internal/app/webbackend.go @@ -422,6 +422,7 @@ 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 + engine.ManagedSessionCloser = managedSSHSessionCloser(deps, target.Backend) entry.engine = engine } if len(entry.terminalSessions) > 0 { diff --git a/internal/app/webbackend_services.go b/internal/app/webbackend_services.go index cbb2929a7..6b383fb73 100644 --- a/internal/app/webbackend_services.go +++ b/internal/app/webbackend_services.go @@ -307,14 +307,40 @@ func (b *WebBackend) checkView(cn string, e *webEntry, snap map[string]CheckSnap if seen && !cs.At.IsZero() { ch.At = cs.At.UTC().Format(time.RFC3339) } - ch.Metrics = webCheckMetrics(e.checkGraphs[cn], e.checkBands[cn]) + ch.Metrics = webCheckMetricsForReadings(ch.Type, e.checkGraphs[cn], e.checkBands[cn], ch.Readings) return ch } +// webCheckMetricsForReadings narrows device-dependent SMART metrics to the +// attributes this drive has actually published. SMART's graph declaration is +// the union of ATA and NVMe attributes, while an individual device normally +// implements only a subset. Retained last-sample readings still prove support +// after a device becomes unavailable, so its historical graph remains useful. +// Fixed-schema checks keep advertising their resolved metrics before the first +// sample; an empty history window is different from an unsupported attribute. +func webCheckMetricsForReadings(checkType string, graphs []checks.GraphMetric, bands []checks.BandMetric, readings []web.WatchReading) []web.CheckMetric { + if checkType != checks.CheckTypeSmart || len(graphs) == 0 { + return webCheckMetrics(graphs, bands) + } + fields := make(map[string]struct{}, len(readings)) + for _, reading := range readings { + fields[reading.Field] = struct{}{} + } + supported := make([]checks.GraphMetric, 0, len(graphs)) + for _, graph := range graphs { + _, current := fields[graph.Key] + _, retained := fields[checks.LastSampleKey(graph.Key)] + if current || retained { + supported = append(supported, graph) + } + } + return webCheckMetrics(supported, bands) +} + // webCheckMetrics is the payload form of one check's resolved line metrics and // state bands. A service check and a host watch both advertise through it, and -// both hand it the lists the recorder itself resolved, so what a panel is -// offered can never differ from what is being persisted. +// both hand it the lists the recorder itself resolved. Capability-dependent +// checks narrow the line list through webCheckMetricsForReadings first. func webCheckMetrics(graphs []checks.GraphMetric, bands []checks.BandMetric) []web.CheckMetric { if len(graphs) == 0 && len(bands) == 0 { return nil diff --git a/internal/app/webbackend_ssh_sessions.go b/internal/app/webbackend_ssh_sessions.go index 1946c2051..4b1b376af 100644 --- a/internal/app/webbackend_ssh_sessions.go +++ b/internal/app/webbackend_ssh_sessions.go @@ -5,12 +5,15 @@ import ( "errors" "fmt" "slices" + "strconv" "strings" "time" "sermo/internal/checks" + "sermo/internal/logind" "sermo/internal/operation" "sermo/internal/process" + "sermo/internal/servicemgr" "sermo/internal/web" ) @@ -27,6 +30,16 @@ type cachedSSHSessions struct { sample checks.SSHSessionSample } +type sshSessionIdentity struct { + pid int + startTicks uint64 + terminal string +} + +func sshSessionMetricKey(session web.SSHSession) string { + return sessionMetricKey(web.SessionKindSSH, strconv.Itoa(session.PID), strconv.FormatUint(session.StartTicks, 10)) +} + func sshSessionFilters(apps []string, selectors []process.Selector) []process.IdentityFilter { if !slices.Contains(apps, sshCatalogApp) { return nil @@ -144,6 +157,21 @@ func freshSSHSessionVerifier(deps Deps, filters []process.IdentityFilter) func(c } } +func managedSSHSessionCloser(deps Deps, backend servicemgr.Backend) func(context.Context, operation.SessionTarget) error { + if backend != servicemgr.BackendSystemd { + return nil + } + if deps.ManagedSSHSessionCloser != nil { + return deps.ManagedSSHSessionCloser + } + client := logind.NewClient() + return func(ctx context.Context, target operation.SessionTarget) error { + return client.CloseRemoteSSHSession(ctx, logind.Target{ + PID: target.PID, StartTicks: target.StartTicks, Terminal: target.Terminal, + }) + } +} + func sshSessionsToWeb(sample checks.SSHSessionSample) []web.SSHSession { result := make([]web.SSHSession, 0, len(sample.SSH)) for _, session := range sample.SSH { @@ -165,9 +193,46 @@ 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. +func (b *WebBackend) appendSSHSessions(result *web.SessionInventory, seen map[sshSessionIdentity]struct{}, service string, entry *webEntry) { + if len(entry.sshSessionFilters) == 0 { + return + } + source := web.SessionSource{Kind: web.SessionKindSSH, Service: service, State: web.SessionSourceAvailable} + sessions, err := b.sshSessions(entry.sshSessionFilters) + if err != nil { + source.State = web.SessionSourceUnavailable + source.Message = err.Error() + result.Sources = append(result.Sources, source) + return + } + if len(sessions.Issues) > 0 { + source.State = web.SessionSourcePartial + 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.engine.ManagedSessionCloser != nil && 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, + }) + } + } + result.Sources = append(result.Sources, source) + for _, session := range sshSessionsToWeb(sessions) { + key := sshSessionIdentity{pid: session.PID, startTicks: session.StartTicks, terminal: session.Terminal} + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + session.Service = service + result.SSH = append(result.SSH, session) + } +} + +// 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 +245,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..2950833af 100644 --- a/internal/app/webbackend_terminal_sessions.go +++ b/internal/app/webbackend_terminal_sessions.go @@ -3,10 +3,8 @@ package app import ( "context" "errors" - "fmt" "maps" "slices" - "strconv" "strings" "time" @@ -33,22 +31,12 @@ func (s terminalSessionSource) config() checks.TerminalSessionConfig { return checks.TerminalSessionConfig{Multiplexer: s.multiplexer, Binary: s.binary, User: s.user, Socket: s.socket} } -type sshSessionIdentity struct { - pid int - startTicks uint64 - terminal string -} - const sessionMetricKeySeparator = "\x00" func sessionMetricKey(parts ...string) string { return strings.Join(parts, sessionMetricKeySeparator) } -func sshSessionMetricKey(session web.SSHSession) string { - return sessionMetricKey(web.SessionKindSSH, strconv.Itoa(session.PID), strconv.FormatUint(session.StartTicks, 10)) -} - func terminalSessionMetricKey(session web.TerminalSession) string { return sessionMetricKey(session.Multiplexer, session.Service, session.Check, session.Identity) } @@ -243,40 +231,6 @@ func attachTerminalSessionIdle(snapshot sessionProcessSnapshot, session *web.Ter } } -func (b *WebBackend) appendSSHSessions(result *web.SessionInventory, seen map[sshSessionIdentity]struct{}, service string, entry *webEntry) { - if len(entry.sshSessionFilters) == 0 { - return - } - source := web.SessionSource{Kind: web.SessionKindSSH, Service: service, State: web.SessionSourceAvailable} - sessions, err := b.sshSessions(entry.sshSessionFilters) - if err != nil { - source.State = web.SessionSourceUnavailable - source.Message = err.Error() - result.Sources = append(result.Sources, source) - return - } - if len(sessions.Issues) > 0 { - source.State = web.SessionSourcePartial - 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 { - source.Issues = append(source.Issues, web.SessionIssue{ - User: issue.User, Terminal: issue.Terminal, Message: issue.Message, - }) - } - } - result.Sources = append(result.Sources, source) - for _, session := range sshSessionsToWeb(sessions) { - key := sshSessionIdentity{pid: session.PID, startTicks: session.StartTicks, terminal: session.Terminal} - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - session.Service = service - result.SSH = append(result.SSH, session) - } -} - func (b *WebBackend) appendTerminalSessions(result *web.SessionInventory, service string, entry *webEntry) { if len(entry.terminalSessions) == 0 { return diff --git a/internal/app/webbackend_test.go b/internal/app/webbackend_test.go index 06742133e..a789e340d 100644 --- a/internal/app/webbackend_test.go +++ b/internal/app/webbackend_test.go @@ -17,6 +17,7 @@ import ( "sermo/internal/execx" "sermo/internal/metrics" "sermo/internal/notify" + "sermo/internal/operation" "sermo/internal/process" "sermo/internal/rules" "sermo/internal/servicemgr" @@ -208,16 +209,48 @@ func TestSSHSessionFiltersUseResolvedAppsMetadata(t *testing.T) { } } +func TestManagedSSHSessionCloserMatchesBackendCapability(t *testing.T) { + target := operation.SessionTarget{PID: 95, StartTicks: 1200, Terminal: "pts/0"} + called := false + injected := func(_ context.Context, got operation.SessionTarget) error { + called = true + if got != target { + t.Fatalf("target = %+v, want %+v", got, target) + } + return nil + } + deps := Deps{ManagedSSHSessionCloser: injected} + if closer := managedSSHSessionCloser(deps, servicemgr.BackendOpenRC); closer != nil { + t.Fatal("OpenRC managed SSH session closer is available, want nil") + } + closer := managedSSHSessionCloser(deps, servicemgr.BackendSystemd) + if closer == nil { + t.Fatal("systemd managed SSH session closer is nil") + } + if err := closer(t.Context(), target); err != nil { + t.Fatalf("close managed SSH session: %v", err) + } + if !called { + t.Fatal("injected managed SSH session closer was not called") + } + if closer := managedSSHSessionCloser(Deps{}, servicemgr.BackendSystemd); closer == nil { + t.Fatal("default systemd managed SSH session closer is nil") + } +} + func TestWebBackendKeepsVerifiedSSHSessionsWithPartialSource(t *testing.T) { b := &WebBackend{ order: []string{"ssh"}, entries: map[string]*webEntry{"ssh": { sshSessionFilters: []process.IdentityFilter{mustWebIdentityFilter(t, "/usr/sbin/sshd", "root")}, + engine: operation.Engine{ManagedSessionCloser: func(context.Context, operation.SessionTarget) error { + return nil + }}, }}, 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,12 +259,18 @@ 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 { t.Fatalf("partial session summary = %+v, want omitted", info.Sessions) } + b.entries["ssh"].engine.ManagedSessionCloser = nil + inventory = b.Sessions(context.Background()) + issue := inventory.Sources[0].Issues[0] + if issue.CanClose || issue.ManagedByLogind { + t.Fatalf("issue without managed closer = %+v, want no close capability", issue) + } } func TestWebBackendShowsTerminalSessionsFromPublishedCheckData(t *testing.T) { @@ -266,7 +305,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 { @@ -2717,3 +2756,76 @@ func TestWebBackendMissingDevicePublishesMissingState(t *testing.T) { t.Errorf("error reading = %q, want the summary %q", got, w.Summary) } } + +func TestWebCheckMetricsUsesSmartDeviceCapabilities(t *testing.T) { + graphs := checks.ResolvedGraphMetrics(checks.CheckTypeSmart, "", map[string]any{}) + readings := []web.WatchReading{ + {Field: checks.SmartFieldTemperature, Value: "41 °C"}, + {Field: checks.LastSampleKey(checks.SmartFieldPowerOnHours), Value: "16mo 20d"}, + } + + got := webCheckMetricsForReadings(checks.CheckTypeSmart, graphs, nil, readings) + if len(got) != 2 { + t.Fatalf("SMART metrics = %+v, want only the two device-supported metrics", got) + } + if got[0].Name != checks.SmartFieldTemperature || got[1].Name != checks.SmartFieldPowerOnHours { + t.Fatalf("SMART metrics = %+v, want temperature and last-known power-on time", got) + } + + users := checks.ResolvedGraphMetrics(checks.CheckTypeUsers, "", map[string]any{}) + if fixed := webCheckMetricsForReadings(checks.CheckTypeUsers, users, nil, nil); len(fixed) != len(users) { + t.Fatalf("fixed-schema metrics = %+v, want all declared metrics", fixed) + } +} + +func TestWebBackendSmartWatchAdvertisesOnlyObservedMetrics(t *testing.T) { + cfg := cfgWithWatches(map[string]any{ + "smart-sda": map[string]any{ + "check": map[string]any{"type": checks.CheckTypeSmart, "device": "/dev/sda"}, + }, + }) + now := time.Unix(1000, 0) + snapshots := NewWatchSnapshots() + snapshots.now = func() time.Time { return now } + snapshots.Publish("smart-sda", checks.CheckTypeSmart, checks.Result{ + Check: "smart-sda", + OK: true, + Message: "smart /dev/sda health=PASSED", + Data: map[string]any{ + checks.SmartFieldTemperature: 41.0, + }, + }) + b := snapshotOnlyBackend(t, cfg, snapshots, now) + + watches := b.Watches(context.Background()) + if len(watches) != 1 { + t.Fatalf("got %d watches, want 1: %+v", len(watches), watches) + } + if got := watches[0].Metrics; len(got) != 1 || got[0].Name != checks.SmartFieldTemperature { + t.Fatalf("SMART metrics = %+v, want only temperature", got) + } +} + +func TestWebBackendSmartServiceCheckAdvertisesOnlyObservedMetrics(t *testing.T) { + now := time.Unix(1000, 0) + snapshots := NewSnapshots() + snapshots.now = func() time.Time { return now } + snapshots.PublishWithCheckTypes("web", map[string]checks.Result{ + "disk": { + Check: "disk", + OK: true, + Data: map[string]any{checks.SmartFieldTemperature: 41.0}, + }, + }, map[string]bool{"disk": true}, map[string]string{"disk": checks.CheckTypeSmart}) + b := webBackendWithEntry(snapshots, []string{"disk"}, map[string]string{"disk": checks.CheckTypeSmart}) + b.now = func() time.Time { return now } + b.entries["web"].interval = time.Minute + b.entries["web"].checkGraphs = map[string][]checks.GraphMetric{ + "disk": checks.ResolvedGraphMetrics(checks.CheckTypeSmart, "", map[string]any{}), + } + + got := b.checkView("disk", b.entries["web"], snapshots.Get("web")) + if len(got.Metrics) != 1 || got.Metrics[0].Name != checks.SmartFieldTemperature { + t.Fatalf("SMART service metrics = %+v, want only temperature", got.Metrics) + } +} diff --git a/internal/app/webbackend_watches.go b/internal/app/webbackend_watches.go index b21b8a017..f97c536d6 100644 --- a/internal/app/webbackend_watches.go +++ b/internal/app/webbackend_watches.go @@ -100,10 +100,10 @@ func (b *WebBackend) applyWatchRuntimeView(view *web.Watch, w *webWatch, activit view.SampleState = b.watchSampleState(w, checkedAt) } view.KeepsSLA = !w.disabled && watchRecordsAvailability(w) - // The same declaration the recorder persists from, so a watch never offers a - // panel for a series nothing is writing. + // Start from the same declaration the recorder persists from, then narrow a + // device-dependent check to the attributes this device actually publishes. if !w.disabled { - view.Metrics = webCheckMetrics(w.graphs, w.bands) + view.Metrics = webCheckMetricsForReadings(w.checkType, w.graphs, w.bands, view.Readings) } observed := b.settling == nil || b.settling.Observed(SettlingWatchKey(w.name)) failed, warning := watchViewState(*view) 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/cli/cli.go b/internal/cli/cli.go index 9ecb1fab8..19c2240e3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -955,6 +955,9 @@ func recordManualActionEvent(ctx context.Context, store manualActionRecorder, re if err == nil { return nil } + if !state.IsSQLiteContention(err) { + return fmt.Errorf("record manual action event: %w", err) + } if ctx.Err() != nil || time.Now().After(deadline) { return fmt.Errorf("record manual action event after retries: %w", err) } diff --git a/internal/cli/cli_action_test.go b/internal/cli/cli_action_test.go index c1c0ba67c..25f622035 100644 --- a/internal/cli/cli_action_test.go +++ b/internal/cli/cli_action_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + sqlite3 "modernc.org/sqlite/lib" + "sermo/internal/config" "sermo/internal/execx" "sermo/internal/operation" @@ -773,17 +775,26 @@ type flakyRecorder struct { failures int calls int records []state.EventRecord + err error } func (f *flakyRecorder) RecordEvent(e state.EventRecord) (int64, error) { f.calls++ if f.calls <= f.failures { - return 0, errors.New("database is locked (5) (SQLITE_BUSY)") + if f.err != nil { + return 0, f.err + } + return 0, sqliteTestError(sqlite3.SQLITE_BUSY) } f.records = append(f.records, e) return int64(f.calls), nil } +type sqliteTestError int + +func (e sqliteTestError) Error() string { return "sqlite test error" } +func (e sqliteTestError) Code() int { return int(e) } + // TestRecordManualActionEventRetriesThroughContention covers the loaded-host // case where sermod holds the SQLite writer lock longer than one busy-timeout // window: the manual audit write must retry instead of failing the completed @@ -811,3 +822,14 @@ func TestRecordManualActionEventStopsOnCancelledContext(t *testing.T) { t.Fatalf("calls = %d, want exactly one attempt under a cancelled context", rec.calls) } } + +func TestRecordManualActionEventDoesNotRetryPermanentFailure(t *testing.T) { + rec := &flakyRecorder{failures: 1000, err: sqliteTestError(sqlite3.SQLITE_READONLY)} + err := recordManualActionEvent(context.Background(), rec, state.EventRecord{Service: "acpid", Action: "stop"}) + if err == nil { + t.Fatal("recordManualActionEvent() = nil, want permanent error") + } + if rec.calls != 1 { + t.Fatalf("calls = %d, want exactly one attempt for a permanent error", rec.calls) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 63646089a..1493aa4f8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6383,19 +6383,26 @@ processes: enable_if: { init: openrc } ` for _, tc := range []struct { - init string + name string + detectedInit string + backend string + envBackend string wantSupervisor bool }{ - {init: "openrc", wantSupervisor: true}, - {init: "systemd", wantSupervisor: false}, + {name: "detected openrc", detectedInit: "openrc", backend: "auto", wantSupervisor: true}, + {name: "detected systemd", detectedInit: "systemd", backend: "auto", wantSupervisor: false}, + {name: "configured openrc overrides detection", detectedInit: "systemd", backend: "openrc", wantSupervisor: true}, + {name: "configured systemd overrides detection", detectedInit: "openrc", backend: "systemd", wantSupervisor: false}, + {name: "environment overrides config", detectedInit: "systemd", backend: "systemd", envBackend: "openrc", wantSupervisor: true}, } { - t.Run(tc.init, func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { oldInit := detectedInit - detectedInit = tc.init + detectedInit = tc.detectedInit defer func() { detectedInit = oldInit }() + t.Setenv(EnvBackendOverride, tc.envBackend) global := writeConfig(t, map[string]string{ - "sermo.yml": baseGlobal, + "sermo.yml": strings.Replace(baseGlobal, "backend: auto", "backend: "+tc.backend, 1), "services/minion.yml": serviceBody, }) cfg, err := loadConfig(t, global) diff --git a/internal/config/enableif.go b/internal/config/enableif.go index b0efa6214..b0734c73d 100644 --- a/internal/config/enableif.go +++ b/internal/config/enableif.go @@ -29,13 +29,13 @@ var ( enableIfKeys = set(keyEnableIfFile, keyEnableIfKey, keyEnableIfContains, keyEnableIfEquals, keyEnableIfMatches, keyEnableIfInit) ) -func pruneEnableIf(v any, path []string) any { +func pruneEnableIf(v any, path []string, backend string) any { switch t := v.(type) { case map[string]any: - return pruneEnableIfMap(t, path) + return pruneEnableIfMap(t, path, backend) case []any: for i := range t { - t[i] = pruneEnableIf(t[i], path) + t[i] = pruneEnableIf(t[i], path, backend) } return t default: @@ -43,26 +43,26 @@ func pruneEnableIf(v any, path []string) any { } } -func pruneEnableIfMap(tree map[string]any, path []string) map[string]any { +func pruneEnableIfMap(tree map[string]any, path []string, backend string) map[string]any { out := make(map[string]any, len(tree)) for key, value := range tree { childPath := appendPath(path, key) if child, ok := value.(map[string]any); ok { if spec, has := child[keyEnableIf]; has { if !enableIfAllowedAt(childPath) { - out[key] = pruneEnableIfMap(child, childPath) + out[key] = pruneEnableIfMap(child, childPath, backend) continue } - if !enableIfHolds(spec) { + if !enableIfHolds(spec, backend) { continue // predicate failed: drop the optional branch } child = cloneMap(child) delete(child, keyEnableIf) - out[key] = pruneEnableIfMap(child, childPath) + out[key] = pruneEnableIfMap(child, childPath, backend) continue } } - out[key] = pruneEnableIf(value, childPath) + out[key] = pruneEnableIf(value, childPath, backend) } return out } @@ -182,7 +182,7 @@ func validateEnableIfPredicates(path string, m map[string]any, add addFunc) int // file, or an absent key all yield false, so an optional component stays // disabled unless explicitly turned on. Predicates: `contains` (substring), // `equals` (exact), `matches` (regex), or `init` (active backend). -func enableIfHolds(spec any) bool { +func enableIfHolds(spec any, backend string) bool { m, ok := spec.(map[string]any) if !ok { return false @@ -195,7 +195,7 @@ func enableIfHolds(spec any) bool { return false } if want, has := m[keyEnableIfInit]; has { - return cfgval.String(want) == detectedInit + return cfgval.String(want) == backend } file, key := cfgval.String(m[keyEnableIfFile]), cfgval.String(m[keyEnableIfKey]) if file == "" || key == "" { diff --git a/internal/config/resolve.go b/internal/config/resolve.go index 986b9a560..e98e8bf66 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -45,7 +45,7 @@ func (c *Config) resolveService(name string, pruneOptional bool) (Resolved, []st return Resolved{Name: name}, []string{err.Error()} } if pruneOptional { - merged = pruneEnableIfMap(merged, nil) + merged = pruneEnableIfMap(merged, nil, effectiveBackend(c)) } expanded, apps, errs := c.resolveExpandedService(merged, canonicalName) @@ -1455,7 +1455,7 @@ func (c *Config) resolveDoc(doc *Document, name string) (Resolved, []string) { // detect a cyclic apps: linkage instead of recursing into a stack overflow. func (c *Config) resolveDocBody(doc *Document, name string, appChain []string) (Resolved, []string) { body := stripMeta(doc.Body) - body = pruneEnableIfMap(body, nil) + body = pruneEnableIfMap(body, nil, effectiveBackend(c)) errs := prepareExpansionInputs(body) vars, varErrs := c.expansionVariables(body, name) errs = append(errs, varErrs...) 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/state/sqlite_error.go b/internal/state/sqlite_error.go new file mode 100644 index 000000000..51ad6d3d3 --- /dev/null +++ b/internal/state/sqlite_error.go @@ -0,0 +1,28 @@ +package state + +import ( + "errors" + + sqlite3 "modernc.org/sqlite/lib" +) + +const sqlitePrimaryResultCodeMask = 0xff + +type sqliteCodeError interface { + Code() int +} + +// IsSQLiteContention reports whether err is transient SQLite writer +// contention. Extended result codes retain their primary code in the low byte. +func IsSQLiteContention(err error) bool { + var sqliteErr sqliteCodeError + if !errors.As(err, &sqliteErr) { + return false + } + switch sqliteErr.Code() & sqlitePrimaryResultCodeMask { + case sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED: + return true + default: + return false + } +} diff --git a/internal/state/sqlite_error_test.go b/internal/state/sqlite_error_test.go new file mode 100644 index 000000000..937c6b622 --- /dev/null +++ b/internal/state/sqlite_error_test.go @@ -0,0 +1,35 @@ +package state + +import ( + "errors" + "fmt" + "testing" + + sqlite3 "modernc.org/sqlite/lib" +) + +type sqliteTestError int + +func (e sqliteTestError) Error() string { return "sqlite test error" } +func (e sqliteTestError) Code() int { return int(e) } + +func TestIsSQLiteContention(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {name: "nil"}, + {name: "generic", err: errors.New("database unavailable")}, + {name: "busy", err: sqliteTestError(sqlite3.SQLITE_BUSY), want: true}, + {name: "wrapped locked", err: fmt.Errorf("record event: %w", sqliteTestError(sqlite3.SQLITE_LOCKED)), want: true}, + {name: "extended busy", err: sqliteTestError(sqlite3.SQLITE_BUSY | 2<<8), want: true}, + {name: "read only", err: sqliteTestError(sqlite3.SQLITE_READONLY)}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := IsSQLiteContention(tc.err); got != tc.want { + t.Fatalf("IsSQLiteContention(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} 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/virt/network.go b/internal/virt/network.go index f94fe62f7..742b72774 100644 --- a/internal/virt/network.go +++ b/internal/virt/network.go @@ -189,7 +189,11 @@ func (m NetworkManager) Stop(ctx context.Context, _ string) error { if err != nil { return "", fmt.Errorf("network %q xml: %w", m.Spec.Network, err) } - return networkBridgeName(xmlDesc), nil + bridge, err := networkBridgeName(xmlDesc) + if err != nil { + return "", fmt.Errorf("network %q xml: %w", m.Spec.Network, err) + } + return bridge, nil }) if err != nil { return err @@ -256,7 +260,7 @@ func (m NetworkManager) sessionClient(socket string) func(time.Duration) (Networ // keeps its interfaces attached, so pausing never makes a destroy safe. func domainHoldsInterfaces(state libvirt.DomainState) bool { switch state { - case libvirt.DomainShutoff, libvirt.DomainNostate, libvirt.DomainCrashed: + case libvirt.DomainShutoff, libvirt.DomainCrashed: return false default: return true @@ -300,13 +304,14 @@ type networkBridgeXML struct { } // networkBridgeName extracts the network's bridge device from its XML; "" -// when the network declares none. -func networkBridgeName(desc string) string { +// when the network declares none. Invalid XML is not equivalent to no bridge: +// callers use this identity to prove that no live guest is attached. +func networkBridgeName(desc string) (string, error) { var parsed networkBridgeXML if err := xml.Unmarshal([]byte(desc), &parsed); err != nil { - return "" + return "", fmt.Errorf("parse network description: %w", err) } - return parsed.Bridge.Name + return parsed.Bridge.Name, nil } type domainInterfacesXML struct { diff --git a/internal/virt/network_test.go b/internal/virt/network_test.go index 9f55ed4ee..8b0ee0b64 100644 --- a/internal/virt/network_test.go +++ b/internal/virt/network_test.go @@ -146,6 +146,7 @@ func TestNetworkManagerStopRefusesAttachedGuests(t *testing.T) { {name: "running on network", state: libvirt.DomainRunning, xml: domOnNetworkXML}, {name: "running on bridge", state: libvirt.DomainRunning, xml: domOnBridgeXML}, {name: "paused on network", state: libvirt.DomainPaused, xml: domOnNetworkXML}, + {name: "unknown state on network", state: libvirt.DomainNostate, xml: domOnNetworkXML}, } { t.Run(tc.name, func(t *testing.T) { client := &fakeNetworkClient{ @@ -206,19 +207,30 @@ func TestNetworkManagerStopDestroysIdleNetwork(t *testing.T) { // cannot be read the bridge attachment cannot be proven absent, so the destroy // must not run. func TestNetworkManagerStopFailsClosedOnUnverifiableGuests(t *testing.T) { - client := &fakeNetworkClient{ - netXML: testNetXML, - xmlErr: errors.New("xml unavailable"), - domains: []libvirt.Domain{{Name: "kvm1"}}, - states: map[string]libvirt.DomainState{"kvm1": libvirt.DomainRunning}, - domXML: map[string]string{"kvm1": domElsewhereXML}, - } - err := networkManagerWith(client).Stop(context.Background(), "libvirt-net-default") - if err == nil { - t.Fatal("Stop() = nil, want fail-closed error") - } - if slicesContains(client.calls, "net-destroy") { - t.Fatalf("calls = %v: destroy ran without verification", client.calls) + for _, tc := range []struct { + name string + netXML string + xmlErr error + }{ + {name: "read error", netXML: testNetXML, xmlErr: errors.New("xml unavailable")}, + {name: "invalid xml", netXML: "Sermo @@ -89,6 +89,7 @@

Sessions

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