336 lines
9.5 KiB
Bash
Executable File
336 lines
9.5 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# shellcheck shell=bash
|
|
|
|
# Caddy webserver JSON log parser
|
|
# version 0.1.0
|
|
#
|
|
# This script reads a Caddy logfile in JSON format and
|
|
# outputs it in Apache Common Log Format.
|
|
#
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
# Copyright 2024 Forza <forza@tnonline.net>
|
|
|
|
|
|
# Default output format
|
|
format="common"
|
|
# Default config file
|
|
config_file=""
|
|
|
|
# JSON selectors available in Caddy JSON logs
|
|
# shellcheck disable=SC2016
|
|
declare -A selectors=(
|
|
[ts]='.ts'
|
|
[datetime]='.ts | strftime("%Y-%m-%d %H:%M:%S")'
|
|
[datetime_l]='.ts | strflocaltime("%Y-%m-%d %H:%M:%S %Z")'
|
|
[datetime_ms]='(.ts | tostring | split(".") | .[1][:3]) as $ms | .ts | strflocaltime("%Y-%m-%d %H:%M:%S.") + $ms + strflocaltime(" %Z")'
|
|
[datetime_iso]='.ts | todateiso8601'
|
|
[client_ip]='.request.client_ip'
|
|
[remote_ip]='.request.remote_ip'
|
|
[remote_port]='.request.remote_port'
|
|
[proto]='.request.proto'
|
|
[method]='.request.method'
|
|
[host]='.request.host'
|
|
[uri]='.request.uri'
|
|
[user_agent]='.request.headers["User-Agent"][0]'
|
|
[referer]='.request.headers.Referer[0]'
|
|
[accept]='.request.headers.Accept[0]'
|
|
[accept_encoding]='.request.headers["Accept-Encoding"][0]'
|
|
[tls_resumed]='.request.tls.resumed'
|
|
[tls_version]='.request.tls.version as $version |
|
|
if $version == 769 then "TLS 1.0"
|
|
elif $version == 770 then "TLS 1.1"
|
|
elif $version == 771 then "TLS 1.2"
|
|
elif $version == 772 then "TLS 1.3"
|
|
else $version end'
|
|
[tls_cipher_suite]='.request.tls.cipher_suite as $cs |
|
|
if $cs == 5 then "TLS_RSA_WITH_RC4_128_SHA"
|
|
elif $cs == 10 then "TLS_RSA_WITH_3DES_EDE_CBC_SHA"
|
|
elif $cs == 47 then "TLS_RSA_WITH_AES_128_CBC_SHA"
|
|
elif $cs == 53 then "TLS_RSA_WITH_AES_256_CBC_SHA"
|
|
elif $cs == 60 then "TLS_RSA_WITH_AES_128_CBC_SHA256"
|
|
elif $cs == 156 then "TLS_RSA_WITH_AES_128_GCM_SHA256"
|
|
elif $cs == 157 then "TLS_RSA_WITH_AES_256_GCM_SHA384"
|
|
elif $cs == 49159 then "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA"
|
|
elif $cs == 49169 then "TLS_ECDHE_RSA_WITH_RC4_128_SHA"
|
|
elif $cs == 49170 then "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA"
|
|
elif $cs == 49171 then "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"
|
|
elif $cs == 49172 then "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA"
|
|
elif $cs == 49161 then "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA"
|
|
elif $cs == 49162 then "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA"
|
|
elif $cs == 49191 then "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256"
|
|
elif $cs == 49199 then "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256"
|
|
elif $cs == 49187 then "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256"
|
|
elif $cs == 49195 then "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
|
|
elif $cs == 49200 then "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384"
|
|
elif $cs == 49196 then "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
|
|
elif $cs == 52392 then "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
|
|
elif $cs == 52393 then "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
|
|
elif $cs == 4865 then "TLS_AES_128_GCM_SHA256"
|
|
elif $cs == 4866 then "TLS_AES_256_GCM_SHA384"
|
|
elif $cs == 4867 then "TLS_CHACHA20_POLY1305_SHA256"
|
|
elif $cs == 22016 then "TLS_FALLBACK_SCSV"
|
|
elif $cs == 52392 then "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305"
|
|
elif $cs == 52393 then "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305" else $cs end'
|
|
[tls_proto]='.request.tls.proto'
|
|
[tls_server_name]='.request.tls.server_name'
|
|
[bytes_read]='.bytes_read'
|
|
[user_id]='.user_id'
|
|
[duration]='.duration'
|
|
[size]='.size'
|
|
[status]='.status'
|
|
[rh_content_length]='.resp_headers["Content-Length"][0]'
|
|
[rh_content_type]='.resp_headers["Content-Type"][0]'
|
|
[rh_server]='.resp_headers.Server[0]'
|
|
[rh_x_frame_options]='.resp_headers["X-Frame-Options"][0]'
|
|
[rh_last_modified]='.resp_headers["Last-Modified"][0]'
|
|
[rh_alt_svc]='.resp_headers["Alt-Svc"][0]'
|
|
[rh_vary]='.resp_headers.Vary[0]'
|
|
[rh_etag]='.resp_headers.Etag[0]'
|
|
[rh_x_content_type_options]='.resp_headers["X-Content-Type-Options"][0]'
|
|
[rh_referrer_policy]='.resp_headers["Referrer-Policy"][0]'
|
|
[rh_cache_control]='.resp_headers["Cache-Control"][0]'
|
|
[rh_accept_ranges]='.resp_headers["Accept-Ranges"][0]'
|
|
[rh_content_security_policy]='.resp_headers["Content-Security-Policy"][0]'
|
|
[rh_strict_transport_security]='.resp_headers["Strict-Transport-Security"][0]'
|
|
[rh_permissions_policy]='.resp_headers["Permissions-Policy"][0]'
|
|
)
|
|
|
|
# Allows adding various placeholders to the output
|
|
declare -A placeholders=(
|
|
[_]=' '
|
|
[space]=' '
|
|
[tab]='\t'
|
|
[q]='\"'
|
|
[quote]='\"'
|
|
[squote]="'"
|
|
[i]='|'
|
|
[pipe]='|'
|
|
[lsqb]='['
|
|
[rsqb]=']'
|
|
[lbrace]='('
|
|
[rbrace]=')'
|
|
[lcurly]='{'
|
|
[rcurly]='}'
|
|
[colon]=':'
|
|
[semicolon]=';'
|
|
[comma]=','
|
|
[dot]='.'
|
|
[slash]='/'
|
|
[backslash]='\\'
|
|
[hyphen]='-'
|
|
[underscore]='_'
|
|
[plus]='+'
|
|
[equals]='='
|
|
[ampersand]='&'
|
|
[percent]='%'
|
|
[dollar]='$'
|
|
[exclamation]='!'
|
|
)
|
|
|
|
# Show command line syntax
|
|
show_help() {
|
|
cat <<END
|
|
Usage: $0 [-c | -C | -s "selectors"] [-F <config_file>] filename
|
|
|
|
Options:
|
|
-c, --common Apache Common Log Format (default)
|
|
-C, --combined Apache Combined Log Format
|
|
-s, --selector Use a space separated list of selectors
|
|
-F, --config-file Use a configuration file
|
|
-h, --help Show this help message and exit
|
|
|
|
END
|
|
}
|
|
|
|
die() {
|
|
local message="${1:-Died}"
|
|
if [ $DEBUG -eq 1 ]; then
|
|
echo "${BASH_SOURCE[1]}: line ${BASH_LINENO[0]}: ${FUNCNAME[1]}: $message." >&2
|
|
else
|
|
echo "$message" >&2
|
|
fi
|
|
exit 1
|
|
}
|
|
|
|
debug() {
|
|
if [ "$DEBUG" -eq 1 ]; then
|
|
if [ -n "$2" ]; then
|
|
eval "$2"
|
|
else
|
|
echo "$1"
|
|
fi
|
|
fi
|
|
}
|
|
# Function to output logs in a custom format
|
|
log_format() {
|
|
# Generate the jq filter string dynamically
|
|
local filter_parts=""
|
|
local output_parts=""
|
|
for var in "$@"; do
|
|
debug "log_format: var: $var"
|
|
if [ -n "${placeholders[$var]}" ]; then
|
|
debug "log_format: placeholder: $var"
|
|
output_parts+="\(\$${var})"
|
|
filter_parts+="
|
|
| ( \"${placeholders[$var]}\" ) as \$$var"
|
|
elif [ -n "${selectors[$var]}" ]; then
|
|
debug "log_format: selector: $var"
|
|
# 'jq' needs to test if the selector exists and if it is empty
|
|
output_parts+="\(\$${var})"
|
|
filter_parts+="
|
|
| ( if ${selectors[$var]} == \"\" then \"-\"
|
|
else ${selectors[$var]}
|
|
end ) as \$$var"
|
|
fi
|
|
done
|
|
|
|
if [ $DEBUG -eq 1 ]; then
|
|
echo jq -r ". ${filter_parts} | \"${output_parts}\"" "${file}"
|
|
else
|
|
jq -r ". ${filter_parts} | \"${output_parts}\"" "${file}"
|
|
fi
|
|
}
|
|
|
|
######################
|
|
# Start main section #
|
|
######################
|
|
|
|
# Enable enable debug output
|
|
DEBUG=$((DEBUG == 1 ? 1 : 0))
|
|
|
|
debug "main: command_line options: $#"
|
|
# Parse command line options
|
|
while [ "$#" -gt 0 ]; do
|
|
case $1 in
|
|
-c|--common)
|
|
format="common"
|
|
shift
|
|
;;
|
|
-C|--combined)
|
|
format="combined"
|
|
shift
|
|
;;
|
|
-F|--config-file)
|
|
config_file="$2"
|
|
if [ -z "${config_file}" ]; then
|
|
die "Error: No configuration file specified."
|
|
fi
|
|
use_config_file=1
|
|
shift 2
|
|
;;
|
|
-s|--selector)
|
|
use_selectors_c="$2"
|
|
if [ -z "${use_selectors_c}" ]; then
|
|
die "Error: No output selectors specified."
|
|
fi
|
|
format="custom"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
show_help
|
|
exit 0
|
|
;;
|
|
*)
|
|
file="$1"
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Check if input files are readable
|
|
if [ -z "${file}" ]; then
|
|
show_help
|
|
die "Error: No input file provided."
|
|
elif [ ! -r "${file}" ]; then
|
|
die "Error: Input file '${file}' is not readable."
|
|
fi
|
|
|
|
if [ ! -z ${use_config_file+x} ]; then
|
|
if [ -r "${config_file}" ]; then
|
|
debug "load_config: config_file: '$config_file'"
|
|
. "${config_file}" || die "Error: Could not load config file"
|
|
debug "load_config: format: $format"
|
|
debug "load_config: use_selectors: $use_selectors"
|
|
debug - 'for a in "${!placeholders[@]}"; do echo "load_config: placeholders: ${a}: \"${placeholders[$a]}\" " ; done'
|
|
debug - 'for a in "${!selectors[@]}"; do echo "load_config: selectors: $a: \"${selectors[$a]}\" " ; done'
|
|
else
|
|
die "Error: Could not read config file '${config_file}'"
|
|
fi
|
|
fi
|
|
|
|
if [ -n "${use_selectors_c}" ]; then
|
|
use_selectors="${use_selectors_c}"
|
|
debug "main: use_selectors override: $use_selectors"
|
|
fi
|
|
|
|
# Process JSON file using the chosen format
|
|
debug "main: format: $format"
|
|
case "$format" in
|
|
common)
|
|
# Common Log format
|
|
log_format \
|
|
client_ip \
|
|
space user_id \
|
|
space lsqb datetime rsqb \
|
|
space quote method \
|
|
space uri space proto quote \
|
|
space status space size
|
|
;;
|
|
combined)
|
|
# Combined Log format
|
|
# '_' can be used instead of 'space'
|
|
# 'q' can be used instead of 'quote'
|
|
log_format \
|
|
client_ip \
|
|
_ user_id \
|
|
_ lsqb datetime rsqb \
|
|
_ q method _ uri _ proto q \
|
|
_ status \
|
|
_ size \
|
|
_ q referer q \
|
|
_ q user_agent q
|
|
;;
|
|
custom)
|
|
debug "main: calling: log_format $use_selectors"
|
|
# Only use specified selectors
|
|
# shellcheck disable=SC2086
|
|
log_format $use_selectors
|
|
;;
|
|
esac
|
|
|
|
|
|
|
|
####
|
|
# A note on datetime format in 'jq'.
|
|
# 1. there is no subsecond support
|
|
# 2. timezone support is currently not available in
|
|
# todateiso8601 builtin jq datetime function.
|
|
# 3. strftime function can be used, but is platform
|
|
# dependant and some formating styles may not be
|
|
# implented or may not properly.
|
|
# 4. strflocaltime converts UTC source to local timezone.
|
|
# 5. jq assumes epoch time to be UTC.
|
|
# https://jqlang.github.io/jq/manual/#dates
|
|
|
|
####
|
|
# Apaches common and combined log formats are widely supported
|
|
# in log readers and easily read by humans.
|
|
|
|
# https://httpd.apache.org/docs/current/da/logs.html
|
|
#
|
|
# Apache Commong Log format:
|
|
# "%h %l %u %t \"%r\" %>s %b"
|
|
|
|
# Apache Combined LogFormat:
|
|
# "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\""
|
|
#
|
|
# Where:
|
|
# %h is the remote host (client IP)
|
|
# %l is the remote logname (not used, so we'll use -)
|
|
# %u is the authenticated user
|
|
# %t is the time the request was received
|
|
# %r is the request line from the client ("method uri proto")
|
|
# %>s is the status code
|
|
# %b is the size of the object returned to the client
|