Particularidades de lastlog
En un RHEL 8 recién instalado, /var/log/lastlog no llama la atención. En el mismo RHEL 8 unido a un dominio por SSSD, después de que un solo usuario del dominio inicie sesión, ls -l puede informar un archivo de cientos de gigabytes. du -h sobre ese mismo archivo devuelve unos pocos kilobytes.
El archivo es sparse, y eso explica la diferencia entre los dos números. Lo que explica por qué es sparse es una sola línea de código.
El UID es la dirección
/var/log/lastlog guarda el último inicio de sesión de cada usuario. No tiene índice, ni cabecera, ni claves, ni base de datos. La posición del registro dentro del archivo es el UID. En shadow-utils el cálculo aparece idéntico en la ruta de lectura y en la de escritura:
offset = (off_t) pw->pw_uid * sizeof (ll);
El archivo se trata como un arreglo en disco cuyo índice es el UID, exactamente igual que array[i] vive en base + i * sizeof(elemento). Para leer el último login de cualquier usuario se hace un fseeko a ese offset y se lee un registro. Acceso directo, sin parsear nada, sin mantener una estructura auxiliar.
El (off_t) merece atención. La conversión está antes de la multiplicación, y no es un detalle cosmético: fuerza a que la operación se haga en 64 bits. Si se multiplicara primero en 32 bits, un UID grande desbordaría antes de convertir. Un UID de 20 000 000 por 292 bytes da unos 5840 millones, que no entra en 32 bits. El offset resultante apuntaría a cualquier parte.
El registro, y por qué mide lo que mide
La struct lastlog no la define shadow: viene de glibc —se declara en <bits/utmp.h>, que un programa obtiene incluyendo <lastlog.h>—. Son tres campos:
| Offset | Campo | Tamaño | Contenido |
|---|---|---|---|
| 0 | ll_time |
4 bytes | segundos desde epoch del último login |
| 4 | ll_line |
32 bytes | terminal: pts/0, tty1
|
| 36 | ll_host |
256 bytes | host remoto |
| total | 292 bytes |
Ese 292 no es universal, y ahí hay una particularidad que se pasa por alto. El campo ll_time está definido bajo una condición:
#if __WORDSIZE_TIME64_COMPAT32 __uint32_t ll_time; #else __time_t ll_time; #endif
En x86-64 esa macro vale 1, ll_time ocupa 4 bytes y el registro mide 292. En aarch64 vale 0, ll_time pasa a ser un __time_t de 8 bytes, y el registro mide 296. Mismo archivo, mismo programa, distinta grilla.
La consecuencia práctica es que /var/log/lastlog no es portable entre arquitecturas. Copiado de un x86-64 a un aarch64, cada registro se lee corrido, y no hay número de versión ni campo de tipo que permita detectarlo. Es un volcado binario de una estructura de C, con la endianness y el padding de la máquina que lo escribió. Ese ll_time condicional además arrastra el problema del año 2038.
El nombre del usuario no está guardado
Mirá de nuevo la tabla: hay tiempo, terminal y host. No hay nombre. La identidad del usuario no está en el registro, está en la posición del registro. Si el offset es UID × 292, entonces UID = offset / 292, y eso es todo lo que el archivo sabe sobre quién es quién.
De ahí sale un comportamiento que sorprende cuando aparece. Si dos entradas de /etc/passwd comparten UID:
alice:x:1000:1000::/home/alice:/bin/bash bob:x:1000:1000::/home/bob:/bin/bash
las dos cuentas comparten físicamente el mismo registro. lastlog recorre /etc/passwd con getpwent() y procesa cada entrada por separado, así que imprime dos filas, con nombres distintos y exactamente la misma fecha, la misma terminal y el mismo host. Si entró alice, la salida muestra a bob con esa fecha.
En escritura es peor de razonar. lastlog -S -u bob hace getpwnam("bob") para obtener el UID, y desde ahí todo el trabajo se hace por UID. Lo que se actualiza es el registro del UID 1000, indistinguible del de alice.
No es un defecto de lastlog. Es lo que significa tratar el UID como identidad, que es exactamente lo que hace el kernel: los permisos, la propiedad de los archivos y las credenciales de proceso van por UID. Los nombres son etiquetas en /etc/passwd. Dos nombres con el mismo UID son el mismo usuario a todos los efectos, y lastlog hereda esa ambigüedad en lugar de inventarse una propia.
Un corolario del mismo diseño: como lastlog recorre passwd en vez de recorrer el archivo, los registros de usuarios ya borrados siguen físicamente en disco pero no se listan. Y si nsswitch está respaldado por SSSD contra un directorio de decenas de miles de usuarios, ese getpwent() enumera toda la base remota. El cuello de botella pasa a ser NSS, no el archivo.
Leer es seguro; escribir es lo que infla
Hay una asimetría en el código que determina cuándo el archivo crece.
En lectura, antes de posicionarse se comprueba que el registro entre dentro del archivo:
if (offset + sizeof (ll) <= statbuf.st_size)
Si el UID cae más allá del final, no se lee nada: la estructura se llena de ceros con memzero y se imprime **Never logged in**. Consultar el lastlog de un usuario con UID enorme no agranda el archivo. Y como un agujero también se lee como ceros, el código trata igual al "usuario dentro de un agujero" y al "usuario fuera del archivo". Ambos son lo mismo: nadie inició sesión.
En escritura no hay ninguna comprobación de límites: se hace fseeko al offset y se escribe. Un solo login —o un solo lastlog -S -u <usuario>— con un UID alto extiende el archivo hasta ese offset y crea el agujero intermedio.
Por eso el tamaño aparente del archivo no depende de cuántos usuarios tenés, sino del UID más alto que alguna vez haya escrito un registro.
Los números con un directorio corporativo
Con cuentas locales, los UID rondan el millar y el archivo es irrelevante. Con Active Directory o LDAP vía SSSD y mapeo algorítmico de SID a POSIX ID, el panorama cambia. Los defaults de SSSD ubican el rango de UID mapeados entre ldap_idmap_range_min = 200000 y ldap_idmap_range_max = 2000200000, repartido en slices de 200000, y el slice que le toca a cada dominio se elige por hash de su SID. Es decir: el UID de un usuario de dominio puede caer en cualquier punto de ese rango.
A 292 bytes por registro:
| UID que inició sesión | Tamaño aparente |
|---|---|
| 1 000 | 285 KiB |
| 1 000 000 | 278 MiB |
| 100 000 000 | 27,2 GiB |
| 145 000 000 | 39,4 GiB |
| 2 000 199 999 (tope del rango) | 543,9 GiB |
El archivo visto como un arreglo en disco: cada login escribe su registro en offset = UID × 292, y los agujeros entre los UID reales no ocupan disco. La frontera administrativa entre cuentas de sistema y de persona (UID 999/1000) cae dentro de un mismo bloque; un solo login de dominio salta a diez millones de bloques de distancia y fija el tamaño aparente.
El espacio real en disco sigue siendo proporcional a la cantidad de registros escritos. El disco no se llena. Lo que se rompe es todo lo que lea el archivo secuencialmente.
El manual lo dice sin vueltas:
The lastlog file is a database which contains info on the last login of each user. You should not rotate it. It is a sparse file, so its size on the disk is usually much smaller than the one shown by "ls -l" (which can indicate a really big file if you have in passwd users with a high UID). You can display its real size with "ls -s".
Rotar el archivo con logrotate usando copy o copytruncate implica leerlo y reescribirlo. Los 543,9 GiB aparentes se convierten en 543,9 GiB reales.
La misma advertencia aplica al backup a nivel de archivo. La receta es excluir /var/log/lastlog del job, y si hace falta conservar el dato de auditoría, volcarlo a un archivo normal:
lastlog > /var/backups/lastlog.txt
Ese archivo pesa lo que corresponde al padrón real de usuarios. El mismo criterio aplica a /var/log/faillog, que no comparte el formato del registro pero sí el esquema: su código calcula offset = (off_t) uid * sizeof (fl); y produce un archivo disperso por la misma razón.
Por qué wtmp no tiene este problema
lastlog y wtmp suelen mencionarse juntos, y son modelos opuestos.
lastlog es una tabla de estado: un slot por usuario, indexado por UID, que se sobrescribe en cada login. Responde "¿cuándo entró por última vez cada usuario?" con acceso directo, y no guarda historia.
wtmp es un libro de contabilidad: registros struct utmp que se agregan al final, en orden cronológico. Cada login, logout, arranque o cambio de runlevel suma un registro. Responde "¿qué pasó en este sistema y en qué orden?".
lastlog |
wtmp |
|
|---|---|---|
| Modelo | arreglo indexado por UID | log secuencial, append-only |
| Escritura | sobrescribe el slot del UID | agrega un registro al final |
| ¿Guarda el nombre? | no, es la posición | sí, en ut_user
|
| Historial | solo el último login | todos los eventos |
| Tamaño | ∝ UID máximo (con agujeros) | ∝ cantidad de eventos (denso) |
| ¿Sparse? | sí | no |
| ¿Rotar? | no | sí, con logrotate
|
struct utmp mide 384 bytes y sí incluye el nombre (ut_user) y un campo ut_type que clasifica el evento. Por eso last puede reconstruir sesiones completas y mostrar reinicios, algo que lastlog no puede hacer ni en principio. Crece linealmente con los eventos, así que hay que rotarlo; no tiene agujeros, así que se puede.
Visto desde arriba, lastlog es un índice materializado del "último USER_PROCESS por usuario" que podría derivarse recorriendo wtmp. La ganancia de ese índice es el acceso O(1). El precio es el archivo disperso.
Dónde está esto hoy
En la familia Enterprise Linux el esquema sigue siendo el clásico. RHEL 8, 9 y 10 —y sus derivados Rocky, Alma y Oracle Linux— empaquetan el binario lastlog en shadow-utils (4.6, 4.9 y 4.15 respectivamente) y mantienen pam_lastlog en la pila de PAM. El archivo indexado por UID es lo que hay, y lo que hay que recordar excluir del backup y de la rotación.
RHEL 10 tiene una particularidad que conviene no dar por sentada. Trae util-linux 2.40, que es la versión donde upstream incorporó liblastlog2, pero el paquete se compila con --disable-liblastlog2: no hay binario lastlog2 ni módulo pam_lastlog2 en los repositorios. Tener la versión no implica tener la funcionalidad.
Lo que sí cambió en RHEL 10 es el anuncio. Red Hat declaró deprecadas las interfaces utmp y utmpx de glibc y avisó que se eliminan en RHEL 11. El motivo que da es el desbordamiento de su contador en 2106; el de lastlog, con su ll_time de 32 bits con signo, vence bastante antes.
Fedora ya hizo el cambio. Desde Fedora 43 el default del sistema es lastlog2, con pam_lastlog2 en la pila de PAM. En una Fedora 44 al día, el paquete shadow-utils ya no provee un binario lastlog; el que hay es lastlog2, y viene de util-linux. Los datos están en /var/lib/lastlog/lastlog2.db, una base SQLite indexada por nombre de usuario, y /var/log/lastlog queda como un archivo vacío de 0 bytes.
El esquema de esa base dice, en una línea, todo lo que cambió:
Name TEXT PRIMARY KEY: el nombre del usuario está guardado y es la clave. La identidad dejó de ser la posición, que es justamente lo que hacía sparse al archivo viejo y lo que provocaba las colisiones de UID. Sin índice posicional no hay agujeros que llenar ni tamaño aparente que explicar. Time INTEGER es un entero de 64 bits, sin la condicional por arquitectura ni el tope de 2038. Y aparece un campo que la struct no tenía: Service, el servicio PAM que originó la sesión.
En cualquiera de los dos casos, qué esquema tenés a mano se comprueba con un comando:
rpm -ql shadow-utils | grep -i lastlog
Fuentes y más recursos
-
lastlog(8)— la fuente de la advertencia "You should not rotate it" y de la nota sobre el archivo sparse yls -s. -
utmp(5)— definestruct utmp, sus camposut_typeyut_user, y la diferencia entre/run/utmp(sesiones activas) y/var/log/wtmp(historial). -
man 5 sssd-ldap— sección ID MAPPING: los defaultsldap_idmap_range_min,ldap_idmap_range_maxyldap_idmap_range_sizeque ubican los UID de dominio en el rango alto. -
Fedora Change: Migrate to lastlog2 — la migración a
lastlog2como default del sistema, desde Fedora 43. -
RHEL 10 — Deprecated functionality — la deprecación de las interfaces
utmpyutmpxde glibc, con la eliminación anunciada para RHEL 11. -
util-linux.specde CentOS Stream 10 — el--disable-liblastlog2que deja a RHEL 10 sinlastlog2pese a tener util-linux 2.40. -
pam_lastlog2(8)— el módulo PAM que reemplaza apam_lastlog: Y2038-safe y con backend SQLite3, en util-linux desde 2.40.



