File: //proc/self/root/scripts/log_retention
#!/usr/local/cpanel/3rdparty/bin/perl
# Copyright 2026 WebPros International, LLC
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.
package scripts::log_retention;
use cPstrict;
use Getopt::Long ();
use Try::Tiny;
use File::Find;
use File::Path qw(remove_tree);
use File::Basename;
use Time::HiRes;
use Cpanel::AccessIds::ReducedPrivileges ();
use Cpanel::Config::LoadCpConf ();
use Cpanel::Config::Users ();
use Cpanel::Hooks ();
use Cpanel::Logger ();
use Cpanel::LogManager ();
use Cpanel::SafeRun::Object ();
use Cpanel::YAML ();
use constant {
PLUGIN_DIR => '/var/cpanel/log_retention/plugins',
};
# Built-in log types
my %BUILTIN_LOG_TYPES = (
web => {
get_default_retention => \&_get_web_default_retention,
get_user_retention => \&_get_web_user_retention,
sys_paths => \&_get_web_sys_paths,
user_paths => \&_get_web_user_paths,
description => 'Web server logs (Apache/NGINX access and error logs)',
},
);
# Combined log types (built-in + plugins)
my %LOG_TYPES;
sub script {
my (@args) = @_;
local $| = 1;
# Load built-in types first, then plugins
%LOG_TYPES = %BUILTIN_LOG_TYPES;
_load_plugin_log_types();
my %opts = (
help => 0,
run => 0,
type => [],
verbose => 0,
);
my $getopt_result = Getopt::Long::GetOptionsFromArray(
\@args,
'help|h' => \$opts{help},
'run' => \$opts{run},
'type=s@' => $opts{type},
'verbose|v' => \$opts{verbose},
);
if ( !$getopt_result || $opts{help} ) {
_usage();
return 0;
}
my $logger = Cpanel::Logger->new();
if ( $opts{run} ) {
return _run_retention_cleanup( $opts{type}, $opts{verbose}, $logger );
}
else {
return _show_log_types( $opts{type}, $opts{verbose}, $logger );
}
}
sub _usage {
print <<"EOF";
Usage: $0 [options]
Log retention management utility for cPanel & WHM
Options:
--help, -h Show this help message
--run Execute log retention cleanup
--type TYPE Process specific log type(s) only (can be repeated)
--verbose, -v Enable verbose output
Examples:
$0 # Show all log types and their settings
$0 --run # Process all log types
$0 --run --type web # Process only web logs
$0 --run --type web --type custom # Process web and custom log types
$0 --verbose # Show detailed information
Built-in log types:
EOF
for my $type ( sort keys %BUILTIN_LOG_TYPES ) {
printf " %-10s %s\n", $type, $BUILTIN_LOG_TYPES{$type}{description};
}
# Show plugin log types if any
my @plugin_types = grep { !exists $BUILTIN_LOG_TYPES{$_} } keys %LOG_TYPES;
if (@plugin_types) {
print "\nPlugin log types:\n";
for my $type ( sort @plugin_types ) {
printf " %-10s %s\n", $type, $LOG_TYPES{$type}{description};
}
}
print "\nPlugin directory: " . PLUGIN_DIR . "\n";
print "\n";
return;
}
sub _show_log_types {
my ( $requested_types, $verbose, $logger ) = @_;
my @types_to_show = @{$requested_types} ? @{$requested_types} : sort keys %LOG_TYPES;
print "Log Retention Configuration:\n";
print "=" x 50 . "\n";
for my $type (@types_to_show) {
if ( !exists $LOG_TYPES{$type} ) {
$logger->warn("Unknown log type: $type");
next;
}
my $config = $LOG_TYPES{$type};
my $default_retention = $config->{get_default_retention}->();
print "\nType: $type\n";
print "Description: $config->{description}\n";
print "Configured system-wide retention: ";
if ( $default_retention == 0 ) {
print "Never delete\n";
}
else {
print "$default_retention days\n";
}
if ($verbose) {
print "System paths:\n";
my @sys_paths = $config->{sys_paths}->();
for my $path (@sys_paths) {
print " - $path\n";
}
print "User retention setting: ~<user>/.cpanel-logs (web-log-retention-days)\n";
}
}
print "\n";
return 0;
}
sub _run_retention_cleanup {
my ( $requested_types, $verbose, $logger ) = @_;
my @types_to_process = @{$requested_types} ? @{$requested_types} : sort keys %LOG_TYPES;
$logger->info("Starting log retention cleanup process");
for my $type (@types_to_process) {
if ( !exists $LOG_TYPES{$type} ) {
$logger->warn("Unknown log type: $type - skipping");
next;
}
$logger->info("Processing log type: $type");
my $start_time = Time::HiRes::time();
try {
_process_log_type( $type, $verbose, $logger );
}
catch {
$logger->error("Failed to process log type '$type': $_");
};
my $duration = sprintf( "%.2f", Time::HiRes::time() - $start_time );
$logger->info("Completed processing '$type' in ${duration}s");
}
$logger->info("Log retention cleanup process completed");
return 0;
}
sub _process_log_type {
my ( $type, $verbose, $logger ) = @_;
my $config = $LOG_TYPES{$type};
my $default_retention = $config->{get_default_retention}->();
my $files_processed = 0;
my $process_success = 1;
# Check if running as non-root user
my $is_root = ( $> == 0 );
my $running_user = $is_root ? undef : getpwuid($>);
eval {
# Process system paths
# Skip if default retention is 0 = never delete
# Also skip system paths if running as non-root user
if ( !$is_root ) {
$logger->info("Running as non-root user - skipping system paths for type: $type") if $verbose;
}
elsif ( $default_retention == 0 ) {
$logger->info("System retention set to 'never delete' - skipping system paths for type: $type") if $verbose;
}
else {
$logger->info("Processing system logs for type: $type");
my @sys_paths = $config->{sys_paths}->();
for my $path (@sys_paths) {
$files_processed += _cleanup_logs_in_path(
{
base_path => $path,
retention_days => $default_retention,
log_type => $type,
user => undef,
verbose => $verbose,
logger => $logger,
}
);
}
}
# Process user paths
# If running as non-root, only process the current user's logs
$logger->info("Processing user logs for type: $type");
my @users = $is_root ? Cpanel::Config::Users::getcpusers() : ($running_user);
for my $user (@users) {
my $user_retention = $config->{get_user_retention}->($user) // $default_retention;
if ( $user_retention == 0 ) {
$logger->info("User '$user' has retention set to 'never delete' - skipping") if $verbose;
next;
}
my @user_paths = $config->{user_paths}->($user);
for my $path (@user_paths) {
$files_processed += _cleanup_logs_in_path(
{
base_path => $path,
retention_days => $user_retention,
log_type => $type,
user => $user,
verbose => $verbose,
logger => $logger,
}
);
}
}
1;
} or do {
$process_success = 0;
$logger->error("Processing failed: $@");
};
$logger->info( "Processed type '$type': $files_processed files deleted, status: " . ( $process_success ? 'success' : 'failed' ) );
return;
}
sub _find_user_log_files ($cutoff_time) {
my $archives = Cpanel::LogManager::list_logs();
my @files;
for my $archive ( @{$archives} ) {
if ( $archive->{mtime} && $archive->{mtime} < $cutoff_time ) {
push @files, $archive->{path};
}
}
return @files;
}
sub _find_system_log_files ( $base_path, $cutoff_time ) {
my @files;
my $wanted = sub {
return unless -f $_;
return if -l $_; # Skip symlinks to prevent symlink attack vulnerabilities
return if $_ eq '.' or $_ eq '..';
# Skip current/active log files
return if $_ =~ /\.log$/ && $_ !~ /\.(gz|bz2|\d+)$/;
# Look for rotated logs: .log.1, .log.gz, .log.20241201, etc.
return unless $_ =~ /\.log\.(?:\d+(?:\.gz|\.bz2)?|gz|bz2|\d{8}(?:\.gz|\.bz2)?)$/;
my $mtime = ( stat($_) )[9];
if ( $mtime && $mtime < $cutoff_time ) {
push @files, $File::Find::name;
}
};
find( { wanted => $wanted, no_chdir => 1 }, $base_path );
return @files;
}
sub _cleanup_logs_in_path {
my ($args) = @_;
my $base_path = $args->{base_path};
my $retention_days = $args->{retention_days};
my $log_type = $args->{log_type};
my $user = $args->{user};
my $verbose = $args->{verbose};
my $logger = $args->{logger};
return 0 unless -d $base_path;
my $cutoff_time = time() - ( $retention_days * 24 * 60 * 60 );
my @files_to_delete;
# Define the file finding operation
my $find_files_coderef = sub {
@files_to_delete =
$user
? _find_user_log_files($cutoff_time)
: _find_system_log_files( $base_path, $cutoff_time );
return;
};
# Find rotated log files - drop privileges for user paths to prevent
# symlink attacks and unauthorized access (TOCTOU mitigation)
if ( $user && $> == 0 && $user ne 'root' ) {
Cpanel::AccessIds::ReducedPrivileges::call_as_user( $find_files_coderef, $user );
}
else {
$find_files_coderef->();
}
if ( !@files_to_delete ) {
return 0;
}
$logger->info(
sprintf(
"Found %d log files to delete in %s (retention: %d days%s)",
scalar(@files_to_delete),
$base_path,
$retention_days,
$user ? " for user: $user" : ""
)
) if $verbose || @files_to_delete > 10;
# Execute pre-deletion hook
try {
Cpanel::Hooks::hook(
{
category => 'Log::Retention',
event => 'pre_deletion',
stage => 'pre',
},
{
log_type => $log_type,
user => $user,
base_path => $base_path,
files_to_delete => \@files_to_delete,
retention_days => $retention_days,
}
);
}
catch {
$logger->warn("Pre-deletion hook failed: $_");
};
# Define the deletion operation
my $deleted_count = 0;
my $delete_files_coderef = sub {
for my $file (@files_to_delete) {
try {
unlink($file) or die "Failed to delete $file: $!";
$deleted_count++;
$logger->info("Deleted: $file") if $verbose;
}
catch {
$logger->warn("Failed to delete $file: $_");
};
}
return;
};
# Delete files - drop privileges for user paths to ensure we can only
# delete files the user owns (prevents privilege escalation)
if ( $user && $> == 0 && $user ne 'root' ) {
Cpanel::AccessIds::ReducedPrivileges::call_as_user( $delete_files_coderef, $user );
}
else {
$delete_files_coderef->();
}
$logger->info( "Deleted $deleted_count files from $base_path" . ( $user ? " (user: $user)" : "" ) );
# Execute post-deletion hook
try {
Cpanel::Hooks::hook(
{
category => 'Log::Retention',
event => 'post_deletion',
stage => 'post',
},
{
log_type => $log_type,
user => $user,
base_path => $base_path,
deleted_count => $deleted_count,
retention_days => $retention_days,
}
);
}
catch {
$logger->warn("Post-deletion hook failed: $_");
};
return $deleted_count;
}
sub _get_web_default_retention {
my $cpconf = Cpanel::Config::LoadCpConf::loadcpconf();
return $cpconf->{'web_log_retention_days'} // 0;
}
sub _get_web_user_retention {
my ($user) = @_;
my $user_home = _get_user_home($user);
return unless $user_home;
my $config_path = "$user_home/.cpanel-logs";
return unless -e $config_path;
require Cpanel::Config::LoadConfig;
my @pwent = getpwnam($user);
return unless @pwent;
my $conf_ref;
if ( $> == 0 && $user ne 'root' ) {
$conf_ref = Cpanel::AccessIds::ReducedPrivileges::call_as_user(
sub { Cpanel::Config::LoadConfig::loadConfig($config_path) },
$pwent[2], $pwent[3]
);
}
else {
$conf_ref = Cpanel::Config::LoadConfig::loadConfig($config_path);
}
my $value = $conf_ref->{'web-log-retention-days'};
return unless defined $value && $value =~ /^[0-9]+$/;
return int($value);
}
sub _get_web_sys_paths {
my @paths;
# Apache system logs
push @paths, '/usr/local/apache/logs' if -d '/usr/local/apache/logs';
push @paths, '/var/log/apache2' if -d '/var/log/apache2';
push @paths, '/var/log/httpd' if -d '/var/log/httpd';
# NGINX system logs
push @paths, '/var/log/nginx' if -d '/var/log/nginx';
# cPanel domlogs
push @paths, '/usr/local/apache/domlogs' if -d '/usr/local/apache/domlogs';
return @paths;
}
sub _get_web_user_paths {
my ($user) = @_;
my $user_home = _get_user_home($user);
return () unless $user_home;
my @paths;
# web specific logs in user's ~/logs directory ???
my $logs_dir = "$user_home/logs";
push @paths, $logs_dir if -d $logs_dir;
return @paths;
}
sub _get_user_home {
my ($user) = @_;
my @pwent = getpwnam($user);
return @pwent ? $pwent[7] : undef;
}
#
# Plugin System
#
# Third-party plugins can be installed by dropping a YAML file in PLUGIN_DIR.
# Each plugin YAML file should define handlers that point to executable scripts.
#
# Example plugin YAML structure:
#
# name: myapp
# description: "MyApp application logs"
# handlers:
# get_default_retention: /opt/cpanel/myapp/bin/log_retention_default
# get_user_retention: /opt/cpanel/myapp/bin/log_retention_user
# sys_paths: /opt/cpanel/myapp/bin/log_retention_sys_paths
# user_paths: /opt/cpanel/myapp/bin/log_retention_user_paths
#
# Handler scripts should:
# - get_default_retention: Print the default retention days (integer) to STDOUT
# - get_user_retention: Accept username as $1, print retention days to STDOUT (or nothing for default)
# - sys_paths: Print one path per line to STDOUT
# - user_paths: Accept username as $1, print one path per line to STDOUT
#
sub _load_plugin_log_types {
my $plugin_dir = PLUGIN_DIR;
return unless -d $plugin_dir;
opendir my $dh, $plugin_dir or return;
while ( my $file = readdir($dh) ) {
next unless $file =~ /^([a-z][a-z0-9_-]*)\.(?:yaml|yml)$/i;
my $potential_name = $1;
next unless -f "$plugin_dir/$file";
try {
my $config = Cpanel::YAML::LoadFile("$plugin_dir/$file");
_register_plugin_log_type( $config, "$plugin_dir/$file" );
}
catch {
warn "Failed to load plugin from $file: $_\n";
};
}
closedir $dh;
return;
}
sub _register_plugin_log_type {
my ( $config, $config_path ) = @_;
# Validate required fields
my $name = $config->{name};
unless ( $name && $name =~ /^[a-z][a-z0-9_-]*$/i ) {
warn "Plugin config $config_path: 'name' is required and must be alphanumeric\n";
return;
}
# Don't allow overriding built-in types
if ( exists $BUILTIN_LOG_TYPES{$name} ) {
warn "Plugin config $config_path: Cannot override built-in log type '$name'\n";
return;
}
my $handlers = $config->{handlers};
unless ( $handlers && ref($handlers) eq 'HASH' ) {
warn "Plugin config $config_path: 'handlers' section is required\n";
return;
}
# Validate required handlers exist and are executable
for my $required (qw(get_default_retention sys_paths)) {
my $handler = $handlers->{$required};
unless ( $handler && -f $handler && -x $handler ) {
warn "Plugin config $config_path: Handler '$required' ($handler) must exist and be executable\n";
return;
}
}
# Register the plugin log type with wrapper functions
$LOG_TYPES{$name} = {
description => $config->{description} || "Plugin: $name",
get_default_retention => sub { _call_plugin_handler( $handlers->{get_default_retention}, 'scalar' ) },
get_user_retention => sub { _call_plugin_handler( $handlers->{get_user_retention}, 'scalar', @_ ) },
sys_paths => sub { _call_plugin_handler( $handlers->{sys_paths}, 'list' ) },
user_paths => sub { _call_plugin_handler( $handlers->{user_paths}, 'list', @_ ) },
_plugin_config => $config,
};
return 1;
}
sub _call_plugin_handler {
my ( $handler_path, $return_type, @args ) = @_;
# If no handler defined (optional handlers like get_user_retention)
return $return_type eq 'list' ? () : undef unless $handler_path;
return $return_type eq 'list' ? () : undef unless -f $handler_path && -x $handler_path;
my $result = Cpanel::SafeRun::Object->new(
program => $handler_path,
args => \@args,
timeout => 30,
);
if ( $result->CHILD_ERROR() ) {
warn "Plugin handler $handler_path failed: " . ( $result->stderr() || 'unknown error' ) . "\n";
return $return_type eq 'list' ? () : undef;
}
my $output = $result->stdout() // '';
chomp $output;
if ( $return_type eq 'scalar' ) {
# Return first line as scalar (e.g., retention days)
my ($value) = split /\n/, $output, 2;
return $value;
}
else {
# Return all lines as list (e.g., paths)
return grep { length $_ } split /\n/, $output;
}
}
exit( __PACKAGE__->script(@ARGV) || 0 ) if !caller();
1;
__END__
=head1 NAME
scripts::log_retention - Log retention management utility for cPanel & WHM
=head1 SYNOPSIS
/usr/local/cpanel/scripts/log_retention [options]
Options:
--help, -h Show help message
--run Execute log retention cleanup
--type TYPE Process specific log type(s) only
--verbose, -v Enable verbose output
=head1 DESCRIPTION
This script manages log file retention across different log types in cPanel & WHM.
It supports:
=over 4
=item * System-wide default retention policies
=item * User-specific retention overrides
=item * Multiple log types (web, and extensible for others)
=item * Hook points for custom pre/post deletion actions
=item * Resource-aware processing
=back
=head1 LOG TYPES
=head2 web
Manages web server logs including:
=over 4
=item * Apache access and error logs
=item * NGINX access and error logs
=item * Domain-specific logs in user directories
=item * System-wide web server logs
=back
=head1 CONFIGURATION
=head2 System Configuration
Default web log retention is configured via WHM Tweak Settings
(web_log_retention_days in /var/cpanel/cpanel.config):
web_log_retention_days=30
Set to 0 to never delete (this is the default).
=head2 User Configuration
Users can override the system default via the .cpanel-logs file
in the user's home directory (~user/.cpanel-logs):
web-log-retention-days=60
Set to 0 to never delete user logs.
If not set or invalid, the system default is used.
=head1 HOOKS
The following hooks are available for custom integration, such as archiving
logs to external storage before deletion:
=head2 Log::Retention::pre_deletion
Called before deleting files. This is the recommended integration point for
archiving solutions - copy or upload the files before they are deleted.
Receives:
=over 4
=item * log_type - The type of logs being processed
=item * user - Username (if processing user logs, undef for system logs)
=item * base_path - Base directory being processed
=item * files_to_delete - Array reference of files to be deleted
=item * retention_days - Retention period in days
=back
=head2 Log::Retention::post_deletion
Called after deleting files. Receives:
=over 4
=item * log_type - The type of logs processed
=item * user - Username (if processing user logs, undef for system logs)
=item * base_path - Base directory processed
=item * deleted_count - Number of files actually deleted
=item * retention_days - Retention period in days
=back
=cut