PHP Classes

File: scripts/caddylog.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/caddylog.php   Download  
File: scripts/caddylog.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 9,876 bytes
 

Contents

Class file image Download
#!/usr/bin/env php
<?php

/**
 * Convert Caddy JSON access logs to Apache combined or common log format.
 *
 * Usage:
 * php scripts/caddylog.php /path/to/caddy.log
 * php scripts/caddylog.php --file=/path/to/caddy.log
 * php scripts/caddylog.php --file=/path/to/caddy.log --tail
 */

main($argv);

function
main(array $argv): void
{
    [
$options, $positional] = parseArgs($argv);

    if (!empty(
$options['help'])) {
       
printUsage($argv[0] ?? 'scripts/caddylog.php');
        exit(
0);
    }

   
$logFile = (string)($options['file'] ?? ($positional[0] ?? ''));
   
$format = strtolower((string)($options['format'] ?? 'combined'));
    if (
$logFile === '') {
       
fwrite(STDERR, "Error: missing Caddy log file path.\n\n");
       
printUsage($argv[0] ?? 'scripts/caddylog.php');
        exit(
1);
    }

    if (!
in_array($format, ['combined', 'common'], true)) {
       
fwrite(STDERR, "Error: unsupported format '{$format}'. Use 'combined' or 'common'.\n");
        exit(
1);
    }

    if (!
is_file($logFile)) {
       
fwrite(STDERR, "Error: log file not found: {$logFile}\n");
        exit(
1);
    }

    if (!
is_readable($logFile)) {
       
fwrite(STDERR, "Error: log file is not readable: {$logFile}\n");
        exit(
1);
    }

    if (!empty(
$options['tail'])) {
       
configureRealtimeOutput();
       
tailLogFile($logFile, $format);
        return;
    }

   
processFile($logFile, $format);
}

function
parseArgs(array $argv): array
{
   
$options = [];
   
$positional = [];

    foreach (
array_slice($argv, 1) as $arg) {
        if (
$arg === '--help' || $arg === '-h') {
           
$options['help'] = true;
            continue;
        }

        if (
$arg === '--tail') {
           
$options['tail'] = true;
            continue;
        }

        if (
str_starts_with($arg, '--file=')) {
           
$options['file'] = substr($arg, 7);
            continue;
        }

        if (
str_starts_with($arg, '--format=')) {
           
$options['format'] = substr($arg, 9);
            continue;
        }

        if (
str_starts_with($arg, '--')) {
           
fwrite(STDERR, "Warning: ignoring unknown option {$arg}\n");
            continue;
        }

       
$positional[] = $arg;
    }

    return [
$options, $positional];
}

function
printUsage(string $script): void
{
   
$name = basename($script);
    echo
"Usage:\n";
    echo
" php {$name} /path/to/caddy.log\n";
    echo
" php {$name} --file=/path/to/caddy.log [--tail]\n";
    echo
"\n";
    echo
"Options:\n";
    echo
" --file=PATH Path to the Caddy JSON access log file\n";
    echo
" --format=FMT Output format: combined (default) or common\n";
    echo
" --tail Follow the file from EOF and emit converted lines in real time\n";
    echo
" --help Show this help message\n";
    echo
"\n";
}

function
processFile(string $logFile, string $format): void
{
   
$handle = fopen($logFile, 'rb');
    if (
$handle === false) {
        throw new
RuntimeException("Failed to open log file: {$logFile}");
    }

    try {
        while ((
$line = fgets($handle)) !== false) {
           
emitConvertedLine($line, $logFile, $format);
        }
    } finally {
       
fclose($handle);
    }
}

function
configureRealtimeOutput(): void
{
    while (
ob_get_level() > 0) {
       
ob_end_flush();
    }

   
ob_implicit_flush(true);
   
stream_set_write_buffer(STDOUT, 0);
}

function
tailLogFile(string $logFile, string $format): void
{
   
$size = @filesize($logFile);
    if (
$size === false) {
        throw new
RuntimeException("Failed to stat log file: {$logFile}");
    }

   
$position = (int)$size;
   
$partial = '';

    while (
true) {
       
clearstatcache(true, $logFile);
       
$size = @filesize($logFile);
        if (
$size === false) {
           
usleep(250000);
            continue;
        }

       
// Handle truncation or rotation to a smaller file.
       
if ($size < $position) {
           
$position = 0;
           
$partial = '';
        }

        if (
$size > $position) {
           
$handle = fopen($logFile, 'rb');
            if (
$handle === false) {
               
usleep(250000);
                continue;
            }

           
fseek($handle, $position);
           
$chunk = stream_get_contents($handle);
           
fclose($handle);

            if (
$chunk !== false && $chunk !== '') {
               
$position += strlen($chunk);
               
$partial .= $chunk;

                while ((
$newlinePos = strpos($partial, "\n")) !== false) {
                   
$line = substr($partial, 0, $newlinePos + 1);
                   
$partial = substr($partial, $newlinePos + 1);
                   
emitConvertedLine($line, $logFile, $format);
                }
            }
        }

       
usleep(250000);
    }
}

function
emitConvertedLine(string $line, string $source, string $format): void
{
   
$converted = convertCaddyLogLine($line, $format);
    if (
$converted === null) {
       
$trimmed = trim($line);
        if (
$trimmed !== '') {
           
fwrite(STDERR, "Warning: skipped unparsable line from {$source}\n");
        }
        return;
    }

   
fwrite(STDOUT, $converted . PHP_EOL);
   
fflush(STDOUT);
   
flush();
}

function
convertCaddyLogLine(string $line, string $format): ?string
{
   
$line = trim($line);
    if (
$line === '') {
        return
null;
    }

    try {
       
$row = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
    } catch (
JsonException $e) {
        return
null;
    }

    if (!
is_array($row)) {
        return
null;
    }

   
$request = is_array($row['request'] ?? null) ? $row['request'] : [];

   
$remoteHost = firstNonEmptyString([
       
$request['client_ip'] ?? null,
       
$request['remote_ip'] ?? null,
       
$row['remote_ip'] ?? null,
    ]) ??
'-';

   
$user = firstNonEmptyString([
       
$row['user_id'] ?? null,
       
$request['remote_user'] ?? null,
       
$request['user_id'] ?? null,
    ]) ??
'-';

   
$time = formatApacheTime($row['ts'] ?? $row['time'] ?? null);

   
$method = firstNonEmptyString([
       
$request['method'] ?? null,
       
$row['method'] ?? null,
    ]) ??
'GET';

   
$uri = firstNonEmptyString([
       
$request['uri'] ?? null,
       
$request['path'] ?? null,
       
$row['uri'] ?? null,
    ]) ??
'/';

   
$proto = firstNonEmptyString([
       
$request['proto'] ?? null,
       
$row['proto'] ?? null,
    ]) ??
'HTTP/1.1';

   
$requestLine = sprintf('%s %s %s', $method, $uri, $proto);

   
$status = extractStatusCode($row, $request);
   
$bytes = extractResponseSize($row);
   
$base = sprintf(
       
'%s - %s [%s] "%s" %s %s',
       
$remoteHost,
       
$user,
       
$time,
       
escapeLogField($requestLine),
       
$status,
       
$bytes
   
);

    if (
$format === 'common') {
        return
$base;
    }

   
$headers = is_array($request['headers'] ?? null) ? $request['headers'] : [];
   
$referer = firstHeaderValue($headers, 'referer') ?? '-';
   
$userAgent = firstHeaderValue($headers, 'user-agent') ?? '-';

    return
sprintf(
       
'%s "%s" "%s"',
       
$base,
       
escapeLogField($referer),
       
escapeLogField($userAgent)
    );
}

function
extractStatusCode(array $row, array $request): string
{
   
$candidates = [
       
$row['status'] ?? null,
       
$row['resp']['status'] ?? null,
       
$request['status'] ?? null,
    ];

    foreach (
$candidates as $candidate) {
        if (
is_int($candidate) || (is_string($candidate) && ctype_digit($candidate))) {
            return (string)
$candidate;
        }
    }

    return
'000';
}

function
extractResponseSize(array $row): string
{
   
$candidates = [
       
$row['size'] ?? null,
       
$row['bytes_written'] ?? null,
       
$row['resp']['size'] ?? null,
    ];

    foreach (
$candidates as $candidate) {
        if (
is_int($candidate)) {
            return (string)
$candidate;
        }
        if (
is_string($candidate) && ctype_digit($candidate)) {
            return
$candidate;
        }
    }

    return
'-';
}

function
formatApacheTime(mixed $value): string
{
    try {
        if (
is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) {
           
$seconds = (float)$value;
           
$date = DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', $seconds));
            if (
$date === false) {
               
$date = new DateTimeImmutable('@' . (string)((int)$seconds));
            }
            return
$date->setTimezone(new DateTimeZone(date_default_timezone_get()))
                ->
format('d/M/Y:H:i:s O');
        }

        if (
is_string($value) && $value !== '') {
            return (new
DateTimeImmutable($value))
                ->
setTimezone(new DateTimeZone(date_default_timezone_get()))
                ->
format('d/M/Y:H:i:s O');
        }
    } catch (
Throwable $e) {
       
// Fall through to current time.
   
}

    return (new
DateTimeImmutable('now'))
        ->
format('d/M/Y:H:i:s O');
}

function
firstNonEmptyString(array $values): ?string
{
    foreach (
$values as $value) {
        if (
is_string($value)) {
           
$trimmed = trim($value);
            if (
$trimmed !== '') {
                return
$trimmed;
            }
        }
    }

    return
null;
}

function
firstHeaderValue(array $headers, string $headerName): ?string
{
    foreach (
$headers as $key => $value) {
        if (
strcasecmp((string)$key, $headerName) !== 0) {
            continue;
        }

        if (
is_array($value)) {
            foreach (
$value as $item) {
                if (
is_string($item) && trim($item) !== '') {
                    return
trim($item);
                }
            }
            return
null;
        }

        if (
is_string($value) && trim($value) !== '') {
            return
trim($value);
        }
    }

    return
null;
}

function
escapeLogField(string $value): string
{
    return
str_replace('"', '\"', $value);
}