Informe de un registro de accesos web
Un script de Bash que resume el registro de accesos de Apache o Nginx: peticiones, visitantes, códigos de respuesta, datos enviados, las IP más activas, la página más pedida y los errores 404. while read, arrays asociativos, expresiones regulares y tuberías con sort.
- while read
- Arrays asociativos
- Expresiones regulares con [[ =~ ]]
- case
- Tuberías con sort y head
- printf
Enunciado
Los servidores web como Apache y Nginx anotan cada petición en un registro de accesos (access.log). Cada línea dice quién pidió qué, cuándo y cómo respondió el servidor, en un formato estándar llamado Common Log Format.
El administrador de una web pequeña quiere un resumen diario sin instalar herramientas de análisis: un script de Bash que lea el registro por la entrada estándar (./informe.sh < access.log) y escriba las cifras importantes.
Un registro real puede tener líneas cortadas (por ejemplo, si el disco se llenó a mitad de una escritura). El script no debe contarlas como peticiones, pero sí decir cuántas había.
Qué tiene que hacer el programa
- Cada línea tiene la forma
IP - - [fecha zona] "MÉTODO ruta PROTOCOLO" código bytes. Separada por espacios, la fecha ocupa dos campos y la petición tres; el código es el 9.º campo y los bytes el 10.º. Los bytes valen-cuando la respuesta no tiene cuerpo (por ejemplo, una redirección): cuentan como 0. - Las líneas vacías se ignoran. Una línea no es válida si no llega al campo de los bytes o si el código no es un número de tres cifras.
- Si no hay ninguna petición válida, escribe solo
No hay peticiones válidas. - Escribe
Peticiones: N,Visitantes distintos: N(el número de IP distintas),Respuestas: 2xx A · 3xx B · 4xx C · 5xx D(cuántas respuestas hay de cada familia, según la primera cifra del código) yDatos enviados: N KB(la suma de los bytes dividida entre 1024, sin decimales). - Bajo
IPs con más peticiones:, las 3 IP con más peticiones (o menos, si no hay tantas), de más a menos: el número en 3 caracteres alineado a la derecha, un espacio y la IP. A igual número, por orden de texto de la IP, carácter a carácter (por eso10.0.0.10va antes que10.0.0.2). Página más pedida: ruta (N veces), con «vez» si es 1. Si hay empate, la primera en orden de texto.Errores 404:y debajo, con dos espacios delante, cada ruta distinta que haya respondido 404, en orden de texto. Si no hay ninguna,Errores 404: ningunoen una sola línea.- Si ha habido líneas no válidas, termina con
Líneas no válidas: N.
Entrada
El registro de accesos por la entrada estándar, una petición por línea en Common Log Format.
Datos de referencia
| Campo | Ejemplo | Significado |
|---|---|---|
| 1 | 192.168.1.10 | IP del cliente |
| 2 y 3 | - - | identidad y usuario (casi siempre vacíos) |
| 4 y 5 | [03/Oct/2026:10:15:32 +0200] | fecha, hora y zona horaria |
| 6, 7 y 8 | "GET /index.html HTTP/1.1" | método, ruta y protocolo |
| 9 | 200 | código de respuesta |
| 10 | 5120 | bytes enviados (- si no hay cuerpo) |
Ejemplos de ejecución
Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.
Diez peticiones
Entrada
192.168.1.10 - - [03/Oct/2026:10:15:32 +0200] "GET /index.html HTTP/1.1" 200 5120 192.168.1.10 - - [03/Oct/2026:10:15:33 +0200] "GET /css/estilos.css HTTP/1.1" 200 2048 10.0.0.7 - - [03/Oct/2026:10:16:01 +0200] "GET /index.html HTTP/1.1" 200 5120 10.0.0.7 - - [03/Oct/2026:10:16:02 +0200] "GET /favicon.ico HTTP/1.1" 404 512 172.16.0.3 - - [03/Oct/2026:10:17:45 +0200] "POST /login HTTP/1.1" 302 - 172.16.0.3 - - [03/Oct/2026:10:17:46 +0200] "GET /panel HTTP/1.1" 200 8192 192.168.1.10 - - [03/Oct/2026:10:18:10 +0200] "GET /admin HTTP/1.1" 404 512 10.0.0.7 - - [03/Oct/2026:10:19:00 +0200] "GET /index.html HTTP/1.1" 200 5120 192.168.1.10 - - [03/Oct/2026:10:20:15 +0200] "GET /api/datos HTTP/1.1" 500 128 8.8.4.4 - - [03/Oct/2026:10:21:30 +0200] "GET /index.html HTTP/1.1" 200 5120
Salida por consola
Peticiones: 10 Visitantes distintos: 4 Respuestas: 2xx 6 · 3xx 1 · 4xx 2 · 5xx 1 Datos enviados: 31 KB IPs con más peticiones: 4 192.168.1.10 3 10.0.0.7 2 172.16.0.3 Página más pedida: /index.html (4 veces) Errores 404: /admin /favicon.ico
Empates y líneas rotas
Entrada
10.0.0.2 - - [04/Oct/2026:09:00:00 +0200] "GET /b HTTP/1.1" 200 1024 10.0.0.10 - - [04/Oct/2026:09:00:01 +0200] "GET /a HTTP/1.1" 200 1024 línea rota 10.0.0.2 - - [04/Oct/2026:09:00:02 +0200] "GET /a HTTP/1.1" 301 - 10.0.0.10 - - [04/Oct/2026:09:00:03 +0200] "GET /b HTTP/1.1" 200 2048 10.0.0.3 - - [04/Oct/2026:09:00:04 +0200] "GET /c HTTP/1.1" 200 1024
Salida por consola
Peticiones: 5 Visitantes distintos: 3 Respuestas: 2xx 4 · 3xx 1 · 4xx 0 · 5xx 0 Datos enviados: 5 KB IPs con más peticiones: 2 10.0.0.10 2 10.0.0.2 1 10.0.0.3 Página más pedida: /a (2 veces) Errores 404: ninguno Líneas no válidas: 1
Guía paso a paso
Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.
1. Partir cada línea en campos con read
read reparte las palabras de la línea entre sus variables. _ es un nombre como otro cualquiera, y se usa por costumbre para los campos que no interesan. La última variable recoge todo lo que sobre, así que una línea con más campos no descoloca nada.
while read -r ip _ _ _ _ metodo ruta _ codigo bytes _; do
...
done2. Validar con una expresión regular
Si a la línea le faltan campos, bytes queda vacía. Para el código, [[ $codigo =~ ^[0-9]{3}$ ]] comprueba que sean exactamente tres cifras. La familia de la respuesta es su primera cifra: ${codigo:0:1}, y con un case sumas en el contador que toca.
if [ -z "$bytes" ] || ! [[ $codigo =~ ^[0-9]{3}$ ]]; then
invalidas=$((invalidas + 1))
continue
fi3. Contar con arrays asociativos
declare -A por_ip crea un array cuyos índices son textos. ${por_ip[$ip]:-0} vale 0 si la IP aún no está, ${#por_ip[@]} es el número de IP distintas y ${!por_ip[@]} la lista de IP. Un array más, rutas_404, sirve como conjunto: solo importan sus claves.
por_ip[$ip]=$(( ${por_ip[$ip]:-0} + 1 ))4. El ranking con sort
Escribe una línea número ip por cada IP y deja que sort las ordene: -k1,1nr ordena por el primer campo como número y al revés, y -k2,2 desempata por la IP. head -n 3 se queda con las tres primeras. export LC_ALL=C hace que el orden de texto sea byte a byte en cualquier sistema.
for ip in "${!por_ip[@]}"; do
echo "${por_ip[$ip]} $ip"
done | sort -k1,1nr -k2,2 | head -n 35. El máximo con desempate
Para la página más pedida basta con recorrer las rutas guardando la mejor: cambia si tiene más peticiones o si tiene las mismas y va antes en orden de texto ([[ $ruta < $mejor ]] compara cadenas).
Resuélvelo aquí
El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.
Ejemplo
192.168.1.10 - - [03/Oct/2026:10:15:32 +0200] "GET /index.html HTTP/1.1" 200 5120 192.168.1.10 - - [03/Oct/2026:10:15:33 +0200] "GET /css/estilos.css HTTP/1.1" 200 2048 10.0.0.7 - - [03/Oct/2026:10:16:01 +0200] "GET /index.html HTTP/1.1" 200 5120 10.0.0.7 - - [03/Oct/2026:10:16:02 +0200] "GET /favicon.ico HTTP/1.1" 404 512 172.16.0.3 - - [03/Oct/2026:10:17:45 +0200] "POST /login HTTP/1.1" 302 - 172.16.0.3 - - [03/Oct/2026:10:17:46 +0200] "GET /panel HTTP/1.1" 200 8192 192.168.1.10 - - [03/Oct/2026:10:18:10 +0200] "GET /admin HTTP/1.1" 404 512 10.0.0.7 - - [03/Oct/2026:10:19:00 +0200] "GET /index.html HTTP/1.1" 200 5120 192.168.1.10 - - [03/Oct/2026:10:20:15 +0200] "GET /api/datos HTTP/1.1" 500 128 8.8.4.4 - - [03/Oct/2026:10:21:30 +0200] "GET /index.html HTTP/1.1" 200 5120
Peticiones: 10 Visitantes distintos: 4 Respuestas: 2xx 6 · 3xx 1 · 4xx 2 · 5xx 1 Datos enviados: 31 KB IPs con más peticiones: 4 192.168.1.10 3 10.0.0.7 2 172.16.0.3 Página más pedida: /index.html (4 veces) Errores 404: /admin /favicon.ico
Solución explicada
Ver la solución completa
1#!/bin/bash
2# Orden de texto byte a byte: así sort y [[ < ]] ordenan igual en cualquier máquina
3export LC_ALL=C
4
5declare -A por_ip por_ruta rutas_404
6total=0
7bytes_total=0
8invalidas=0
9c2=0 c3=0 c4=0 c5=0
10
11while read -r ip _ _ _ _ metodo ruta _ codigo bytes _; do
12 [ -z "$ip" ] && continue
13 if [ -z "$bytes" ] || ! [[ $codigo =~ ^[0-9]{3}$ ]]; then
14 invalidas=$((invalidas + 1))
15 continue
16 fi
17 total=$((total + 1))
18 por_ip[$ip]=$(( ${por_ip[$ip]:-0} + 1 ))
19 por_ruta[$ruta]=$(( ${por_ruta[$ruta]:-0} + 1 ))
20 # Una respuesta sin cuerpo trae «-» en lugar del tamaño
21 [ "$bytes" != "-" ] && bytes_total=$((bytes_total + bytes))
22 case ${codigo:0:1} in
23 2) c2=$((c2 + 1)) ;;
24 3) c3=$((c3 + 1)) ;;
25 4) c4=$((c4 + 1)) ;;
26 5) c5=$((c5 + 1)) ;;
27 esac
28 [ "$codigo" = "404" ] && rutas_404[$ruta]=1
29done
30
31if [ "$total" -eq 0 ]; then
32 echo "No hay peticiones válidas."
33 exit 0
34fi
35
36echo "Peticiones: $total"
37echo "Visitantes distintos: ${#por_ip[@]}"
38echo "Respuestas: 2xx $c2 · 3xx $c3 · 4xx $c4 · 5xx $c5"
39echo "Datos enviados: $((bytes_total / 1024)) KB"
40
41echo "IPs con más peticiones:"
42for ip in "${!por_ip[@]}"; do
43 echo "${por_ip[$ip]} $ip"
44done | sort -k1,1nr -k2,2 | head -n 3 | while read -r n ip; do
45 printf "%3d %s\n" "$n" "$ip"
46done
47
48mejor=""
49for ruta in "${!por_ruta[@]}"; do
50 n=${por_ruta[$ruta]}
51 if [ -z "$mejor" ] || [ "$n" -gt "${por_ruta[$mejor]}" ] || { [ "$n" -eq "${por_ruta[$mejor]}" ] && [[ $ruta < $mejor ]]; }; then
52 mejor=$ruta
53 fi
54done
55veces=${por_ruta[$mejor]}
56[ "$veces" -eq 1 ] && palabra="vez" || palabra="veces"
57echo "Página más pedida: $mejor ($veces $palabra)"
58
59if [ ${#rutas_404[@]} -eq 0 ]; then
60 echo "Errores 404: ninguno"
61else
62 echo "Errores 404:"
63 printf "%s\n" "${!rutas_404[@]}" | sort | while read -r ruta; do
64 echo " $ruta"
65 done
66fi
67
68[ "$invalidas" -gt 0 ] && echo "Líneas no válidas: $invalidas"
69exit 0Todo el recuento se hace en una sola pasada: el bucle while read valida la línea y actualiza los contadores, y el informe se escribe al final con lo acumulado.
El bucle lee directamente de la entrada del script (done sin tubería delante). Si se escribiera cat fichero | while read …, el bucle correría en una subshell y los contadores se perderían al terminar: es uno de los errores más típicos en Bash.
Los arrays asociativos hacen de diccionario: por IP, por ruta y, como conjunto, las rutas con 404. Ordenar sí se delega en sort, que lo hace mejor que cualquier bucle; la tubería del ranking sí corre en subshell, pero ahí solo se escribe.
LC_ALL=C fija el orden byte a byte. Sin él, sort usaría las reglas del idioma del sistema y el mismo script podría ordenar distinto en dos máquinas.
Para ir más allá
- Admite el nombre del fichero como argumento (
$1) y, si no se da, lee de la entrada estándar. - Añade un histograma de peticiones por hora a partir del campo de la fecha.
- Escribe el mismo informe con
awken una sola orden y compara la longitud y la velocidad de las dos versiones.