Apuntes DAM
Volver al inicio

Informe de un registro de accesos web

Ejercicio de BashDifícilUnos 75 minutos

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

  1. 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.
  2. 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.
  3. Si no hay ninguna petición válida, escribe solo No hay peticiones válidas.
  4. 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) y Datos enviados: N KB (la suma de los bytes dividida entre 1024, sin decimales).
  5. 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 eso 10.0.0.10 va antes que 10.0.0.2).
  6. Página más pedida: ruta (N veces), con «vez» si es 1. Si hay empate, la primera en orden de texto.
  7. 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: ninguno en una sola línea.
  8. 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

Campos de una línea del registro
CampoEjemploSignificado
1192.168.1.10IP 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
9200código de respuesta
105120bytes 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.

bash
while read -r ip _ _ _ _ metodo ruta _ codigo bytes _; do
  ...
done
2. 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.

bash
if [ -z "$bytes" ] || ! [[ $codigo =~ ^[0-9]{3}$ ]]; then
  invalidas=$((invalidas + 1))
  continue
fi
3. 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.

bash
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.

bash
for ip in "${!por_ip[@]}"; do
  echo "${por_ip[$ip]} $ip"
done | sort -k1,1nr -k2,2 | head -n 3
5. 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.

🖥️BashInforme de un registro de accesos webDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
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 esperada
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
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
bash
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 0

Todo 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 awk en una sola orden y compara la longitud y la velocidad de las dos versiones.

Dónde se explica