#!/usr/bin/perl
# #
#   @app                ConfigServer Security & Firewall (CSF)
#                       Login Failure Daemon (LFD)
#   @website            https://configserver.dev
#   @docs               https://docs.configserver.dev
#   @download           https://download.configserver.dev
#   @repo               https://github.com/Aetherinox/csf-firewall
#   @copyright          Copyright (C) 2025-2026 Aetherinox
#                       Copyright (C) 2006-2025 Jonathan Michaelson
#                       Copyright (C) 2006-2025 Way to the Web Ltd.
#   @license            GPLv3
#   @updated            02.27.2026
#   
#   This program is free software; you can redistribute it and/or modify
#   it under the terms of the GNU General Public License as published by
#   the Free Software Foundation; either version 3 of the License, or (at
#   your option) any later version.
#   
#   This program is distributed in the hope that it will be useful, but
#   WITHOUT ANY WARRANTY; without even the implied warranty of
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
#   General Public License for more details.
#   
#   You should have received a copy of the GNU General Public License
#   along with this program; if not, see <https://www.gnu.org/licenses>.
# #

# #
#   ConfigServer and Firewall › CSGet
#   
#   Script runs as a cron in:
#       /etc/cron.daily/csget
#       
#   By default, the script sleeps for a random number of seconds in
#   fork/daemon mode:
#       0 - 6 hours (rand(60*60*6) = up to 21600 seconds)
#       
#   After sleep, it connects to CSF api and gets the current ver of CSF:
#       https://download.configserver.dev/csf/version.txt
#       https://raw.githubusercontent.com/Aetherinox/csf-firewall/main/api/templates/versions/csf/version.txt
#       
#   After latest version is fetched from @downloadserver, a file is created in:
#       /var/lib/configserver/csf.txt
#   With contents like:
#       15.10
#       
#   If you run the script with the cmd below; a process is created with a random
#   duration between 0-6 hours.
#       sudo perl /etc/cron.daily/csget
#   
#   You can search for active copies of the process with:
#       ps aux | grep Config
#       root     3830870  0.0  0.1  22280  8980 ?        S    15:41   0:00 ConfigServer Version Check
#       sudo kill -9 3830870
#       
#   If you run the command below; action will immediately start with no lingering process:
#       sudo perl -d:Trace /etc/cron.daily/csget --nodaemon
#   All output will be logged to:
#       /var/log/csf/csget_daemon.log
#   
#   Requires:
#       DEBIAN/UBUNTU               sudo apt update
#                                   sudo apt install libdevel-trace-perl
#       REDHAT                      sudo yum makecache
#                                   sudo dnf install perl-Devel-Trace
#       
#   If you run the script using the cmd below; Action will trigger, 
#   create a child process, and wait 0–6 hours
#       sudo perl -d:Trace /etc/cron.daily/csget --debug
#   Logging will be sent to
#       /var/log/csf/csget_debug.log
#       
#   This script contains two log files:
#       /var/log/csf/csget_daemon.log     logs to this file if --debug mode DISABLED
#       /var/log/csf/csget_debug.log      logs to this file if --debug mode ENABLED
#   
#   @usage      sudo perl /etc/cron.daily/csget
#                   Runs the script normally, no extra warnings, no debugging.
#                   If the script forks (daemonizes), the parent exits normally.
#                   STDOUT/STDERR behavior depends on the script (e.g., closed in fork).
#       
#               sudo perl -w /etc/cron.daily/csget
#                   Enables warnings (-w flag) for uninitialized variables, deprecated
#                   features, or risky operations. Otherwise behaves like plain Perl.
#                   Fork/daemon behavior and STDOUT/STDERR unchanged (aside from warnings).
#       
#               sudo perl -d /etc/cron.daily/csget
#                   Runs the Perl debugger (-d). Stops at the first line and waits for
#                   debugger commands (n=next, c=continue). Can step through lines,
#                   inspect variables, set breakpoints, etc.
#                   Note: If the script forks, child processes won't have the debugger
#                   attached. Script behavior differs from normal run.
#       
#               sudo perl -d:Trace /etc/cron.daily/csget
#                   Special debugger module: Trace. Prints every line executed in real
#                   time. Useful for line-by-line debugging. Fork/daemonization can behave
#                   differently. Script may exit immediately or behave oddly as the
#                   debugger controls STDOUT/STDERR for trace output.
#       
#               Process can be killed with:
#                   ps aux | grep csget
#                   sudo pkill -9 -f "sudo perl -w /etc/cron.daily/csget"
# #

use strict;
use warnings;
use File::Basename;

# #
#   Colors
# #

my $esc                 = "\033";
my $end                 = "${esc}[0m";
my $bgEnd               = "${esc}[49m";
my $fgEnd               = "${esc}[39m";
my $bold                = "${esc}[1m";
my $dim                 = "${esc}[2m";
my $underline           = "${esc}[4m";
my $blink               = "${esc}[5m";

# Foreground colors
my $white           = "${esc}[97m";
my $black           = "${esc}[0;30m";
my $redl            = "${esc}[0;91m";
my $redd            = "${esc}[38;5;196m";
my $magental        = "${esc}[38;5;198m";
my $magentad        = "${esc}[38;5;161m";
my $fuchsial        = "${esc}[38;5;206m";
my $fuchsiad        = "${esc}[38;5;199m";
my $bluel           = "${esc}[38;5;33m";
my $blued           = "${esc}[38;5;27m";
my $greenl          = "${esc}[38;5;47m";
my $greend          = "${esc}[38;5;35m";
my $orangel         = "${esc}[38;5;208m";
my $oranged         = "${esc}[38;5;202m";
my $yellowl         = "${esc}[38;5;226m";
my $yellowd         = "${esc}[38;5;214m";
my $greyl           = "${esc}[38;5;250m";
my $greym           = "${esc}[38;5;244m";
my $greyd           = "${esc}[38;5;240m";
my $navy            = "${esc}[38;5;62m";
my $olive           = "${esc}[38;5;144m";
my $peach           = "${esc}[38;5;204m";
my $cyan            = "${esc}[38;5;6m";

# Background / combined colors
my $bgVerbose       = "${esc}[1;38;5;15;48;5;125m";     # white on purple
my $bgDebug         = "${esc}[1;38;5;15;48;5;237m";     # white on dark grey
my $bgInfo          = "${esc}[1;38;5;15;48;5;27m";      # white on blue
my $bgOk            = "${esc}[1;38;5;15;48;5;64m";      # white on green
my $bgWarn          = "${esc}[1;38;5;16;48;5;214m";     # black on orange/yellow
my $bgDanger        = "${esc}[1;38;5;15;48;5;202m";     # white on orange-red
my $bgError         = "${esc}[1;38;5;15;48;5;160m";     # white on red

# #
#   Define › Flag › Debug
#   
#   0 = normal mode		    no logging, enables daemonization/fork block; logs to /var/log/csf/csget_daemon.log
#   1 = debug mode		    sets logging, disables daemonization/fork block; logs to /var/log/csf/csget_debug.log
# #

our $FLG_DEBUG = 0;

# #
#   Define › Flag › Nosleep
#   
#   0 = disable		        condition is true › script sleeps.
#   1 = enable		        condition is false › script runs immediately.
# #

my $FLG_NOSLEEP = 0;

# #
#   Define › Flag › Return Response
#   
#   0 = disable		        when complete or failed, return nothing
#   1 = enable		        when complete or failed, return response
# #

my $FLG_RETRESPONSE = 0;

# #
#   Define › Log Paths
# #

our $log_dir            = '/var/log/csf';
our $log_file_debug     = "$log_dir/csget_debug.log";
our $log_file_daemon    = "$log_dir/csget_daemon.log";
our $app_name           = "CSF CSGET Perl Updater";
our $app_desc           = "A perl script which allows for automated update checks for the official CSF servers.";
our $app_repo           = "https://github.com/Aetherinox/csf-firewall";
our $proc_name          = "ConfigServer Version Check";
our $proc_path          = "/etc/cron.daily/" . basename($0) ."";
our $log_dbg;
my %versions;
my %config_vals;
my $config_file         = "/etc/csf/csf.conf";
my $version_file        = "/etc/csf/version.txt";
my $get_bin;
my $get_cmd;
my $get_method          = "none";
my $get_status          = 0;
my $col_width_left      = 32;

# #
#   Declare › Diagnostics Module
#   
#   On development servers, install module with
#       sudo dnf install perl-Diagnostics           # RHEL/AlmaLinux
#           OR
#       sudo apt install libdiagnostics-perl        # Debian/Ubuntu
# #

eval
{
    require diagnostics;
    diagnostics->import();
};
if ( $@ )
{
    print "\n" if $FLG_DEBUG;
    warn "  ${yellowl}Diagnostics${redl} perl module not found; continuing without it. You can install this perl module using the commands:${end}\n" if $FLG_DEBUG;
    warn "      ${greyd}sudo dnf install perl-Diagnostics${end}\n" if $FLG_DEBUG;
    warn "      ${greyd}sudo apt install libdiagnostics-perl${end}\n" if $FLG_DEBUG;
    print "\n" if $FLG_DEBUG;
}

# #
#   Declare › Download Servers & Structure
#   
#   https://download.configserver.dev
#       csf
#           changelog.txt
#           install.txt
#           license.txt
#           readme.txt
#           version.txt
#   
#   https://raw.githubusercontent.com/Aetherinox/csf-firewall/main/api/templates/versions
#       csf
#           changelog.txt
#           install.txt
#           license.txt
#           readme.txt
#           version.txt
# #

my @downloadservers = (
        "https://download.configserver.dev"
    #   "https://raw.githubusercontent.com/Aetherinox/csf-firewall/main/api/templates/versions"
);

# #
#   Declare › Fetch Types
#   
#   Map available fetch tools to their command templates
# #

my %fetch = (
    curl => "/usr/bin/curl -skLf -m 120 -o",
    wget => "/usr/bin/wget -q -T 120 -O",
    get  => "/usr/bin/GET -t 120"
);

# #
#   Define › Source Version File
#   
#   Get Version Info
#       csf             ConfigServer and Firewall                   Free
#       cmm             ConfigServer Mail Manage                    Free
#       cse             ConfigServer Explorer                       Free
#       cmq             ConfigServer Mail Queues                    Free
#       cmc             ConfigServer Modsecurity Control            Free
#       cxs             ConfigServer Exploit Scanner                Commercial
#       osm             Outgoing Spam Monitor                       Commercial
#       msfe            MailScanner Front-End                       Commercial
# #

if ( -e "/etc/csf/csf.pl" )
{
	$versions{ "/csf/version.txt" } = "/var/lib/configserver/csf.txt"
}

if ( -e "/etc/cxs/cxs.pl" )
{
	$versions{ "/cxs/version.txt" } = "/var/lib/configserver/cxs.txt"
}

if ( -e "/usr/local/cpanel/whostmgr/docroot/cgi/configserver/cmm.cgi" )
{
	$versions{ "/cmm/cmmversion.txt" } = "/var/lib/configserver/cmm.txt"
}

if ( -e "/usr/local/cpanel/whostmgr/docroot/cgi/configserver/cse.cgi" )
{
	$versions{ "/cse/cseversion.txt" } = "/var/lib/configserver/cse.txt"
}

if ( -e "/usr/local/cpanel/whostmgr/docroot/cgi/configserver/cmq.cgi" )
{
	$versions{ "/cmq/cmqversion.txt" } = "/var/lib/configserver/cmq.txt"
}

if ( -e "/usr/local/cpanel/whostmgr/docroot/cgi/configserver/cmc.cgi" )
{
	$versions{ "/cmc/cmcversion.txt" } = "/var/lib/configserver/cmc.txt"
}

if ( -e "/etc/osm/osmd.pl" )
{
	$versions{ "/osm/osmversion.txt" } = "/var/lib/configserver/osm.txt"
}

if ( -e "/usr/msfe/version.txt" )
{
	$versions{ "/version.txt" } = "/var/lib/configserver/msinstall.txt"
}

if ( -e "/usr/msfe/msfeversion.txt" )
{
	$versions{ "/msfeversion.txt" } = "/var/lib/configserver/msfe.txt"
}

# #
#   Define › Process Name
#   
#   ps aux | grep perl
#   root   120546  0.0  0.1 ... ConfigServer Version Check
# #

$0 = $proc_name;            # change the process name

# #
#   Define › Version › Compare
#   
#   Compare two version numbers
# #

sub version_is_newer
{
    my ($new, $old) = @_;

    my @new_parts   = split /\./, $new;
    my @old_parts   = split /\./, $old;

    # Pad shorter array with zeros
    my $len         = @new_parts > @old_parts ? @new_parts : @old_parts;
    $#new_parts     = $len - 1;
    $#old_parts     = $len - 1;

    $_ ||= 0 for @new_parts, @old_parts;

    for my $i ( 0..$len - 1 )
    {
        return 1 if $new_parts[$i] > $old_parts[$i];
        return 0 if $new_parts[$i] < $old_parts[$i];
    }

    return 0; # same version
}

# #
#   Define › Version › Read File
#   
#   Read version from a file
# #

sub version_read
{
    my ($file) = @_;
    open my $fh, '<', $file or die "Cannot open $file: $!";
    my $line = <$fh>;
    close $fh;
    chomp $line;
    $line =~ s/^\s+|\s+$//g;   # trim whitespace

    # Validate version format
    unless ( $line =~ /^\d+(\.\d+){0,3}$/ )
    {
        die "Invalid version format in $file: '$line'\n";
    }

    return $line;
}

# #
#   Declare › Helper › Get csget process pids
#   
#   @usage                  fetch_csget_pids()
#   @returns                List of PIDs for all csget processes, excluding the current script and parent sudo
# #

sub fetch_csget_pids
{
    my $self_pid    = $$;
    my $parent_pid  = getppid();

    # Get PIDs by script path and by $0 name
    my @pids_path   = map { chomp; $_ } `pgrep -f "$proc_path" 2>/dev/null`;
    my @pids_name   = map { chomp; $_ } `pgrep -f '\Q$proc_name\E' 2>/dev/null`;

    # Merge unique PIDs
    my %seen;
    my @pids        = grep { !$seen{$_}++ } (@pids_path, @pids_name);

    # Remove current script and parent sudo
    @pids           = grep { $_ =~ /^\d+$/ && $_ != $self_pid && $_ != $parent_pid } @pids;

    return @pids;
}

# #
#   Logs › Main
#   
#   Called by:
#       log_dbg
#       log_daemon
# #

sub log_msg
{
    my (%opts)          = @_;
    my $level           = $opts{level}          || 'INFO';      #  INFO, WARN, FAIL, PASS, DBUG
    my $msg             = $opts{msg}            || '';
    my $color_prefix    = $opts{color}          || '';
    my $no_console      = $opts{no_console}     || 0;

    $msg =~ s/\n+$//;   # remove newlines

    # #
    #   Build timestamp for FILE logs only
    # #

    my ($sec,$min,$hour,$mday,$mon,$year) = localtime();
    $mon  += 1;
    $year += 1900;
    $year %= 100;
    my $timestamp = sprintf "%02d/%02d/%02d %02d:%02d:%02d",
        $mon, $mday, $year, $hour, $min, $sec;

    # #
    #   Log › Debug & RETRESPONSE › Console Output
    # #

    if ( ( $FLG_DEBUG || $FLG_RETRESPONSE ) && !$no_console )
    {
        my $tag = sprintf("   %s %-5s%s", $color_prefix, $level, $end);
        my $txt = sprintf("%s  %s%s", $greym, $msg, $end);

        printf "%-44s %-65s\n", $tag, $txt;
    }

    # #
    #   Log › Daemon › File Output
    # #

    if ( !$FLG_DEBUG )
    {
        # #
        #   Always log clean text to daemon log
        # #

        open my $dh, '>>', $log_file_daemon;

        # #
        #   Strip ANSI color codes for clean file logs
        # #
    
        my $plain_msg = $msg;                       # start with message
        $plain_msg =~ s/\e\[[\d;]*m//g;             # remove ANSI codes
        my $clean_color = $color_prefix;           
        $clean_color =~ s/\e\[[\d;]*m//g;           # remove ANSI codes from color prefix
        $plain_msg = $clean_color . $plain_msg;     # prepend clean color label

        print $dh "$timestamp | [$level] $plain_msg\n";
        close $dh;
    }

    # #
    #   Log › Debug › File Output
    # #

    if ( $FLG_DEBUG )
    {
        open my $fh, '>>', $log_file_debug;
        print $fh "$timestamp | [$level] $msg\n";
        close $fh;
    }
}

# #
#   Declare › Helper › Daemon Log
#   
#   @usage                  log_daemon("Some daemon message\n");
#   @returns                null
#                           prints message to log_daemon 
# #

sub log_daemon
{
    my ($msg) = @_;
    log_msg(
        msg         => $msg,
        level       => 'INFO',
        color       => $bgInfo,
        no_console  => 1       # never print daemon logs to console
    );
}

# #
#   Declare › Helper › Debug Logging (w/ optional daemon logging route)
#   
#   @usage                  log_dbg( "This is a debug message" );
#   @returns                null
# #

sub log_dbg
{
    my ($msg) = @_;

    my ($tag, $rest) = $msg =~ /^\[(\w+)\]:\s*(.*)/;
    $tag ||= 'DBUG';            # fallback
    $rest ||= $msg;             # if no match, use whole message

    my $color = $bgDebug;
    if    ( $tag eq 'INFO' )    { $color = $bgInfo }
    elsif ( $tag eq 'PASS' )    { $color = $bgOk }
    elsif ( $tag eq 'WARN' )    { $color = $bgWarn }
    elsif ( $tag eq 'DNGR' )    { $color = $bgError }
    elsif ( $tag eq 'FAIL' )    { $color = $bgDanger }
    elsif ( $tag eq 'DBUG' )    { $color = $bgDebug }
    elsif ( $tag eq 'VRBO' )    { $color = $bgVerbose }

    log_msg(
        msg   => $rest,
        level => $tag,
        color => $color
    );
}

# #
#   Load › Settings
#   
#   Grabs a few csf config settings we'll need in order to confirm the release channel
#   to use when downloading updates.
# # 

if ( -e $config_file )
{
    open my $fh, '<', $config_file or die "Cannot open $config_file: $!";
    while (<$fh>)
    {
        chomp;
        s/#.*$//;              # remove comments
        next if /^\s*$/;       # skip empty lines
        if (/^\s*(\w+)\s*=\s*["']?([^"']+)["']?/)
        {
            $config_vals{$1} = $2;
        }
    }
    close $fh;
}

# #
#   Declare › Flags
#   
#       -r, --response          Run in foreground and show logs. Useful with bash scripts.
#                                   › sudo perl /etc/cron.daily/csget --response
#   
#       -n, --nodaemon          Skips random sleep interval; processes immediately
#                                   › sudo perl /etc/cron.daily/csget --nodaemon
#   
#       -k, --kill              Kills all processes associated with csget.
#                                   › sudo perl /etc/cron.daily/csget --kill
#   
#       -l, --list              Lists all csget processes except this command.
#                                   › sudo perl /etc/cron.daily/csget --list
#       
#       -d, --diag              Show diagnostic information.
#                                   › sudo perl /etc/cron.daily/csget --diag
#   
#       -D, --debug             Show verbose logs and additional details; disables forked child process daemonization.
#                                   › sudo perl /etc/cron.daily/csget --debug
#   
#       -v, --version           Show version information about csget and csf.
#                                   › sudo perl /etc/cron.daily/csget --version
#   
#       -h, --help              Returns help information.
#                                   › sudo perl /etc/cron.daily/csget --help
# #

foreach my $arg ( @ARGV )
{
    if ( $FLG_DEBUG )
    {
        log_dbg( "[DBUG]: Passing arg [ ${peach}\"$arg\"${greym} ]" );
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --response
    #                   Run in foreground and show logs. Useful with bash scripts.
    # #

    if ( $arg =~ /^--response$|^--resp$|^-r$/ )
    {
        $FLG_RETRESPONSE = 1;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --nodaemon
    #                   If specified, csget runs instantly, not on a random timer.
    # #

    elsif ( $arg =~ /^--nosleep$|^-n$|^--nodaemon$|^-N$/ )
    {
        $FLG_NOSLEEP = 1;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --kill
    #                   Kills all processes associated with csget.
    # #

    elsif ( $arg =~ /^--kill$|^-k$/ )
    {

        my @pids = fetch_csget_pids();

        if ( @pids )
        {
            kill 9, @pids;
            print "CSGet processes terminated: @pids\n";
        }
        else
        {
            print "No CSGet processes found to kill.\n";
        }
    
        exit 0;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --list
    #                   Lists all csget processes except this command.
    # #

    elsif ( $arg =~ /^--list$|^-l$/ )
    {
        print "\n";

        my $self_pid   = $$;                # current Perl PID
        my $parent_pid = getppid;           # parent PID (sudo)

        my @lines = grep
        {
            (/\/etc\/cron.daily\/csget/ || /ConfigServer Version Check/) 
            && !/--list/                    # skip the list command itself
            && !/\b$self_pid\b/             # skip current PID
            && !/\b$parent_pid\b/           # skip parent (sudo)
        } `ps aux`;

        if ( @lines )
        {
            print "   ${bluel}CSGet processes currently running:${end}\n";
            print "   ${greyd}The following is a list of processes attached to CSGet${end}\n\n";
            print "   @lines";
        }
        else
        {
            print "   ${greenl}No CSGet processes found.${end}\n";
            print "   ${greyd}No CSGet process are currently running. To start CSGet, ensure the file is in:${end}\n";
            print "   ${magental}    $proc_path${end}\n\n";

            print "   ${greyd}To run CSGet once, execute the command${end}\n";
            print "   ${bluel}    \$ ${greend} sudo ${magental}$proc_path ${yellowl}--nodaemon${end}\n\n";

            print "   ${greyd}To start CSGet cron, execute the command${end}\n";
            print "   ${bluel}    \$ ${greend} sudo ${magental}$proc_path${end}\n\n";

            print "   ${greyd}For a list of command flags and descriptions, execute:${end}\n";
            print "   ${bluel}    \$ ${greend} sudo ${magental}$proc_path ${yellowl}--help${end}\n\n";
        }

        print "\n";

        exit 0;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --diag
    #                   Show diagnostic information.
    # #

    elsif ( $arg =~ /^--diag$|^--diagnostic$|^-d$/ )
    {

        # #
        #   Determine which fetch tool exists
        # #
    
        my ($diagMethod, $diagCmd) = ( "none", "None" );
        foreach my $tool (qw(curl wget get))
        {
            if ( -e "/usr/bin/$tool" )
            {
                $diagMethod     = $tool;
                $diagCmd        = $fetch{ $tool };
                last;
            }
        }

        # #
        #   Pick a server/version for diagnostics
        # #

        my ( $diagUrl, $diagVersion, $diagOut ) = ( "none", "none", "none" );
        my ( $configStatus ) = "None";
        if ( @downloadservers && keys %versions )
        {
            $diagVersion        = (keys %versions)[ 0 ];
            $diagUrl            = $downloadservers[ 0 ] . $diagVersion;

            # #
            #   Build command string based on fetch method
            # #

            if ( $diagMethod eq "get" )
            {
                $diagOut = "$diagCmd $diagUrl > $versions{ $diagVersion }";
            }
            else
            {
                $diagOut = "$diagCmd $versions{ $diagVersion } $diagUrl";
            }
        }

        # #
        #   Config Status
        # #

        if ( -e $config_file )
        {
            $configStatus = "${greenl}(Found)${end}";
        }
        else
        {
            $configStatus = "${redl}(Not Found)${end}";
        }

        # #
        #   Output
        # #

        print "\n";
        print "   ${yellowl}${app_name}${end}\n";
        print "   ${greyd}${app_desc}${end}\n";
        print "   ${greyd}$app_repo${end}\n\n";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Process Name", "${magental}${proc_name}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Process Path", "${magental}${proc_path}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Server URL", "${magental}${diagUrl}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Fetch Package", "${magental}${diagMethod}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Command (Base)", "${magental}${diagCmd}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Command (Out)", "${magental}${diagOut}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Config Path", "${magental}${config_file} ${configStatus}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Log Folder", "${magental}${log_dir}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Log Daemon", "${magental}${log_file_daemon}${end}";
        printf "   %-*s %s\n", $col_width_left, "${greyd}Log Debug", "${magental}${log_file_debug}${end}";
        print "\n";

        exit 0;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --debug
    #                   Show verbose logs and additional details; disables forked child process daemonization.
    # #

    elsif ( $arg =~ /^--debug$|^-D$/ )
    {
        $FLG_DEBUG = 1;
        next;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --version
    #                   Show version information about csget and csf.
    # #

    elsif ( $arg =~ /^--version$|^-v$/ )
    {

        if ( -e $version_file )
        {
            open my $fh, '<', $version_file
                or die "Cannot open $version_file: $!";
            my $version = <$fh>;       # read first line
            chomp $version;            # remove newline
            close $fh;

            print "\n";
            print "   ${yellowl}${app_name}${end}\n";
            print "   ${greyd}ConfigServer Security & Firewall v$version${end}\n";
            print "   ${greyd}${app_desc}${end}\n";
            print "   ${greyd}$app_repo${end}\n";
            print "\n";
        }
        else
        {
            print "   ${redl}CSF version file not found: $version_file${end}\n";
        }

        exit 0;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --help
    #                   Returns help information
    # #

    elsif ( $arg =~ /^--help$|^-h$/ )
    {

        # #
        #   Output
        # #

        print "\n";
        print "   ${yellowl}${app_name}${end}\n";
        print "   ${greyd}${app_desc}${end}\n";
        print "   ${greyd}$app_repo${end}\n\n";

        printf STDERR "   %-5s %-40s\n", "${greyd}Syntax:${end}", "";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}Command${end}                           ", "  ${magental}$proc_path${greyd} [ ${greym}--option ${greyd}[ ${yellowd}arg${greyd} ]${greyd} ]${end}";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}Options${end}                           ", "  ${magental}$proc_path${greyd} [ ${greym}-h${greyd} | ${greym}--help${greyd} ]${end}";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "    ${greym}-A${end}                            ", "     ${white}required";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "    ${greym}-A...${end}                         ", "     ${white}required; multiple can be specified";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "    ${greym}[ -A ]${end}                        ", "     ${white}optional";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "    ${greym}[ -A... ]${end}                     ", "     ${white}optional; multiple can be specified";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "    ${greym}{ -A | -B }${end}                   ", "     ${white}one or the other; do not use both";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}Examples${end}                          ", "  ${magental}$proc_path${end}";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}${end}                                  ", "  ${magental}$proc_path${end} ${yellowl}--nodaemon${end}";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}${end}                                  ", "  ${magental}$proc_path${end} ${yellowl}--debug${end}";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}${end}                                  ", "  ${magental}$proc_path${end} ${yellowl}--nodaemon ${yellowl}--response${end}";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}${end}                                  ", "  ${magental}$proc_path${end} ${yellowl}--diag${end}";
        printf STDERR "   %-5s %-30s %-40s\n", "    ", "${greyd}${end}                                  ", "  ${magental}$proc_path${end} ${yellowl}--list${end}";
        print STDERR "\n";
        printf STDERR "   %-5s %-40s\n", "${greyd}Options:${end}", "";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-r${greyd},${bluel}  --response ${yellowd}${end}                    ", "Run in foreground and show logs. Useful with bash scripts.${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${blued}  ${greyd} ${blued}           ${yellowd}${end}                      ", "    ${greyd}disables forked daemonization${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-n${greyd},${bluel}  --nodaemon ${yellowd}${end}                    ", "Run task immediately, do not start on timed delay.${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${blued}  ${greyd} ${blued}           ${yellowd}${end}                      ", "    ${greyd}no forked daemonization${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-k${greyd},${bluel}  --kill ${yellowd}${end}                        ", "Kills all processes associated with csget.${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-l${greyd},${bluel}  --list ${yellowd}${end}                        ", "Lists all csget processes except this command.${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-d${greyd},${bluel}  --diag ${yellowd}${end}                        ", "Show diagnostic information.${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-D${greyd},${bluel}  --debug ${yellowd}${end}                       ", "Show verbose logs and additional details; disables forked child process daemonization.${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${blued}  ${greyd} ${blued}           ${yellowd}${end}                      ", "    ${greyd}disables forked daemonization${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-v${greyd},${bluel}  --version ${yellowd}${end}                     ", "Show version information about csget and csf.${end}";
        printf STDERR "   %-5s %-81s %-40s\n", "    ", "${bluel}-h${greyd},${bluel}  --help ${yellowd}${end}                        ", "Show this help menu.${end}";
        print STDERR "\n";
        printf STDERR "   %-5s %-40s\n", "${greyd}Tips:${end}", "";
        printf STDERR "   %-5s %-10s %-40s\n", "    ", "${bluel}Run CSGet once${end}                    ", "${bluel}  \$ ${greend}sudo ${magental}$proc_path ${yellowl}--nodaemon${end}";
        printf STDERR "   %-5s %-10s %-40s\n", "    ", "${bluel}Start CSGet cron${end}                  ", "${bluel}  \$ ${greend}sudo ${magental}$proc_path${end}";
        printf STDERR "   %-5s %-10s %-40s\n", "    ", "${bluel}Run using perl (normal)${end}           ", "${bluel}  \$ ${greend}sudo perl ${magental}$proc_path${end}";
        printf STDERR "   %-5s %-10s %-40s\n", "    ", "${bluel}Run using perl (+w warnings)${end}      ", "${bluel}  \$ ${greend}sudo perl ${yellowl}-w ${magental}$proc_path${end}";
        printf STDERR "   %-5s %-10s %-40s\n", "    ", "${bluel}Run using perl (+d debugger)${end}      ", "${bluel}  \$ ${greend}sudo perl ${yellowl}-d ${magental}$proc_path${end}";
        printf STDERR "   %-5s %-10s %-40s\n", "    ", "${bluel}Run using perl (+d:Trace)${end}         ", "${bluel}  \$ ${greend}sudo perl ${yellowl}-d:Trace ${magental}$proc_path${end}";
        print "\n\n";

        exit 0;
    }

    # #
    #   @usage          sudo perl /etc/cron.daily/csget --randomBadFlag
    #                   Specified bad flag doesn't exist
    # #

    else
    {
        print "\n";
        print "   ${redl}Invalid argument: ${yellowl}$arg${end}\n";
        print "   ${greyd}Usage: ${bluel}sudo perl $proc_path ${greyd}[${yellowl} --debug ${greyd}|${yellowl} --kill ${greyd}|${yellowl} --list ${greyd}|${yellowl} --version ${greyd}|${yellowl} --nodaemon ${greyd}]${bluel}${end}\n";
        print "\n";
        exit 1;
    }
}

# #
#   Define › Debug Mode
# #

if ( $FLG_DEBUG )
{
    mkdir $log_dir unless -d $log_dir;

    open $log_dbg, '>>', $log_file_debug or die "Cannot open debug log: $!";
    select((select($log_dbg), $|=1)[0]);        # auto-flush

    my $script_path = `readlink -f $0`;
    chomp $script_path;

    log_dbg( "[DBUG]: csget debug enabled; logging to [ \"$log_dir\" ]" );
}

# #
#   Perl daemonization/fork block
#       sudo perl -w /etc/cron.daily/csget --nodaemon
#       sudo perl -d /etc/cron.daily/csget
#   
#   Parent exits				terminal / cron is free
#   Child continues 			runs in background
# #

unless ( $FLG_DEBUG || $FLG_RETRESPONSE )
{
    if ( my $pid = fork ) { exit 0; }               # parent
    elsif ( defined( $pid ) ) { $pid = $$; }        # child
    else { die "Unable to fork: $!"; }              # cannot fork

    chdir( "/" );
    close( STDIN );
    close( STDOUT );
    close( STDERR );
    open( STDIN,  "<", "/dev/null" );
    open( STDOUT, ">>", "$log_file_daemon" )
        or die "Cannot open STDOUT log: $!";
    open( STDERR, ">>", "$log_file_daemon" )
        or die "Cannot open STDERR log: $!";
}

# #
#   Always make sure log dir exists
# #

mkdir $log_dir unless -d $log_dir;

# #
#   Define › Welcome Print
# #

my $script_path = `readlink -f "$proc_path"`;
chomp( $script_path ); # remove trailing newline
log_daemon( "[INFO]: Found csget path: [$script_path]" );
log_daemon( "[INFO]: Daemon started with PID [ \"$$\" ]" );

# #
#   Action › Create required folders and files
# #

system( "mkdir -p /var/lib/configserver/" );
system( "rm -f /var/lib/configserver/*.txt /var/lib/configserver/*error" );

# #
#   Condition › Update Fetch Command / Binary
#   
#   determine which binary to use for fetching server info
#       /usr/bin/curl
#       /usr/bin/wget
#       /usr/bin/GET
# #

if ( -e "/usr/bin/curl" )
{
    $get_cmd        = $fetch{ 'curl' };
    $get_method     = "curl";
}
elsif ( -e "/usr/bin/wget" )
{
    $get_cmd        = $fetch{ 'wget' };
    $get_method     = "wget";
}
elsif ( -e "/usr/bin/GET" )
{
    $get_cmd        = $fetch{ 'get' };
    $get_method     = "get";
}
else
{
    open( my $ERROR, ">", "/var/lib/configserver/error" );
    log_daemon( "[FAIL]: No download tool found: curl, wget, or get", $ERROR );
    close( $ERROR );
    exit 1;
}

log_dbg( "[PASS]: Found package [ ${greenl}\"$get_method\"${greym} ] using cmd [ ${greenl}\"$get_cmd\"${greym} ]" );

# #
#   Secondary fallback
#   
#   This is here in case curl or wget are detected, but for some reason, the version file
#   cannot be downloaded. 
#   
#   However, GET is a primary option to fetch the version file from the CSF server.
# #

if ( -e "/usr/bin/GET" )
{
	$get_bin = "/usr/bin/GET -sd -t 120"
}

# #
#   Condition › No Source Version Found
#   
#   Originally, this function would unlink the cron
# #

if ( scalar( keys %versions ) == 0 )
{

    # unlink $0;

    log_dbg( "ERROR: No version files to fetch — aborting cron run" );

    # #
    #   mark last run with no versions
    # #

    my $status_file = "/var/lib/configserver/last_run_no_versions";
    if ( !-d "/var/lib/configserver" )
	{
        system( "mkdir -p /var/lib/configserver" ) == 0
            or die "Failed to create /var/lib/configserver for status file";
    }

    system( "touch $status_file" ) == 0
        or warn "Failed to create status file $status_file";

    exit 0;
}

# #
#   Execute › Process Delayed Timer
#   
#   creates a delay for when the cron will actually run. Anywhere between 0 and 6 hours from the 
#       time of this condition being triggered.
#   
#   original csf developer added this delay. we'll assume in order to mitigate floods of
#       traffic from hitting the server all at once.
#   
#   no arg					› $ARGV[0] undefined › condition is true › sleep
#   arg `--nodaemon`        › skip sleep (no background daemon)
#   arg `something else`	› sleep (create forked daemon process)
# #

unless ( $FLG_NOSLEEP )
{
    log_daemon( "[INFO]: Activating sleep mode" );
    system( 'sleep', int( rand( 60 * 60 * 6 ) ) );
}

# #
#   Logic › Get Download Server
#   
#   loop download server array with Fisher-Yates shuffle
#       Randomize order of @downloadservers.
# #

for ( my $x = @downloadservers; --$x; )
{
	my $y = int( rand( $x+1 ) );
	if ( $x == $y ) { next }
	@downloadservers[ $x,$y ] = @downloadservers[ $y,$x ];
}

# #
#   Logic › Loop Update URL
# #

foreach my $server ( @downloadservers )
{

    log_dbg( "[INFO]: Connecting to server [ ${bluel}\"$server\"${greym} ]" );

    # #
    #   Loop $versions
    #   
    #       %versions                               $version
    #       -------------------------------------------------------------------------------
    #       $versions{ "/csf/version.txt" }         = "/var/lib/configserver/csf.txt"
    #       $versions{ "/cxs/version.txt" }         = "/var/lib/configserver/cxs.txt"
    #       $versions{ "/cmm/cmmversion.txt" }      = "/var/lib/configserver/cmm.txt"
    # #

    foreach my $version ( keys %versions )
    {
        # #
        #   Download url
        #       Example: https://download.configserver.dev/csf/version.txt
        # #
    
        my $url = "$server$version";

        log_dbg( "[INFO]: Detected correct remote url as [ ${bluel}\"$url\"${greym} ]" );
    
        # #
        #   Run if local version file does NOT exist
        #   Download new copy of file from CSF server.
        # #

        unless ( -e $versions{ $version } )
        {
            # #
            #   Clean up .error files
            # #

            if ( -e $versions{ $version }.".error" )
            {
                unlink $versions{ $version }.".error";
                log_dbg( "[INFO]: Removed previous error file: ${bluel}$versions{ $version }.error${greym}" );
            }

            # #
            #   Channel › Insiders
            #   Get sponsor license key from CSF config /etc/csf/csf.conf
            # #

            if ( ( $config_vals{SPONSOR_RELEASE_INSIDERS} // 0 ) == 1 && ( $config_vals{SPONSOR_LICENSE} // '' ) ne '' && $version eq "/csf/version.txt" )
            {
                $url .= "?channel=insiders&license=$config_vals{ SPONSOR_LICENSE }";
                log_dbg( "[PASS]: Using release channel [ ${greenl}\"insiders\"${greym} ] from server [ ${greenl}\"$url\"${greym} ]" );
            }

            # #
            #   Channel › Stable
            # #

            else
            {
                log_dbg( "[PASS]: Using release channel [ ${greenl}\"stable\"${greym} ] from server [ ${greenl}\"$url\"${greym} ]" );
            }

            # #
            #   Prepare download
            # #

            log_dbg( "[INFO]: Preparing to download remote [ ${bluel}\"$url\"${greym} ] to local [ ${bluel}\"$versions{ $version }\"${greym} ]" );

            # #
            #   Method: None
            #   
            #   Backup check to ensure we get the proper method.
            # #

            if ( $get_method eq "none" )
            {
                log_dbg( "[FAIL]: GET [ ${redl}\"$get_method\"${greym} ] bad method; aborting process" );
                exit 0;

            }
            elsif ( $get_method eq "get" )
            {
                # #
                #   GET prints to stdout; redirect stdout -> file, stderr -> .error
                # #
            
                my $cmdline = "$get_cmd $url > $versions{ $version } 2> $versions{ $version }.error";
                log_dbg( "[INFO]: Download file using [ ${bluel}\"$get_method\"${greym} ] with cmd [ ${bluel}\"$cmdline\"${greym} ]" );

                my $raw     = system( $cmdline );           # Raw return from system()
                my $exit    = $raw >> 8;                    # Real exit code from raw return; shift right 8 bytes

                log_dbg( "[INFO]: Download with [ ${bluel}\"$get_method\"${greym} ] returned raw response [ ${bluel}\"$raw\"${greym} ]; exit = [ ${bluel}\"$exit\"${greym} ]" );

                # #
                #   Success only if exit == 0 AND file exists and has content
                # #
            
                if ( $exit == 0 && -s $versions{ $version } )
                {
                    $get_status = 0;                            # success

                    # remove stale .error if present and empty
                    if ( -e "$versions{ $version }.error" && -z "$versions{ $version }.error" )
                    {
                        unlink "$versions{ $version }.error";
                    }
                }
                else
                {
                    # #
                    #   GET returns 0                   Success
                    #   GET returns 0 + blank file      Generic failure 1
                    #   GET returns non-zero            Keep exit code (non-zero indicates failure)
                    # #
        
                    $get_status = ( $exit != 0 ) ? $exit : 1;

                    log_dbg( "[WARN]: GET [ ${yellowl}\"$get_method\"${greym} ] failed or produced empty file; status set to [ ${yellowl}\"$get_status\"${greym} ]" );
                }
            }

            # #
            #   Method: curl / wget
            #   
            #   These write to file when invoked with -o/-O; system() exit is reliable
            # #

            else
            {
                my $cmdline = "$get_cmd $versions{ $version } $url";
                log_dbg( "[INFO]: Download file using [ ${bluel}\"$get_method\"${greym} ] with cmd [ ${bluel}\"$cmdline\"${greym} ]" );

                my $raw     = system( $cmdline );
                my $exit    = $raw >> 8;

                log_dbg( "[INFO]: Download with [ ${bluel}\"$get_method\"${greym} ] returned raw response [ ${bluel}\"$raw\"${greym} ]; exit = [ ${bluel}\"$exit\"${greym} ]" );

                # Normalize to 0 (success) / non-zero (failure)
                $get_status = $exit == 0 ? 0 : $exit;
            }

            # #
            #   curl        0           Success 
            #               non-zero    Error           https://curl.se/libcurl/c/libcurl-errors.html
            #   
            #   wget        0           Success
            #               non-zero    Error           https://gnu.org/software/wget/manual/html_node/Exit-Status.html
            #   
            #   get         0           Success
            #               non-zero    Error
            # #

            my %STATUS_TEXT = (
                0 => 'Success',
                1 => 'Connection Error',
                2 => 'Timeout',
                3 => 'Invalid Response',
                4 => 'Permission Denied',
                5 => 'Unknown Error',
            );

            my $status_code = $get_status;
            my $status_text = $STATUS_TEXT{$status_code} // 'Undefined Status';

            log_dbg( "[INFO]: Download with [ ${bluel}\"$get_method\"${greym} ] returned status [ ${bluel}\"$status_text ($get_status)\"${greym} ]"  );

            if ( $get_status )
            {
				if ( $get_bin ne "" )
                {
					open ( my $ERROR, ">", $versions{ $version }.".error" );
                    log_daemon( "[FAIL]: [ \"$get_method\" ]: $server$version -", $ERROR );
					close ( $ERROR );
					my $GETstatus = system( "$get_bin $server$version >> $versions{ $version }".".error" );
				}
                else
                {
					open ( my $ERROR, ">", $versions{ $version }.".error" );
                    log_daemon( "[FAIL]: [ ${redl}\"$get_method\"${greym} ]: Failed to retrieve latest version from ConfigServer", $ERROR );
					close ( $ERROR );
				}
            }
            else
            {
                log_dbg( "[PASS]: Successfully downloaded csf version from [ ${greenl}\"$url\"${greym} ] to file [ ${greenl}\"$versions{ $version }\"${greym} ]" );
            }

            # #
            #   Verify the downloaded file contains a valid version
            # #

            if ( -s $versions{ $version } )        # file exists and has content
            {
                open my $fh, '<', $versions{ $version } or do
                {
                    log_dbg( "[FAIL]: Cannot open [ ${redl}\"$versions{ $version }\" ${greym} ] for reading: [ ${redl}\"$!\" ${greym} ]" );
                    next;
                };

                my $line = <$fh>;                  # read first line
                chomp $line;
                close $fh;

                # regex: one or more digits, followed by 1-3 groups of .digits
                if ( $line =~ /^\d+(\.\d+){0,3}$/ )
                {
                    log_dbg( "[PASS]: File [ ${greenl}\"$versions{ $version }\" ${greym} ] contains valid version: [ ${greenl}\"$line\" ${greym} ]" );
                    $get_status = 0;                # mark as passed
                }
                else
                {
                    log_dbg( "[WARN]: File [ ${yellowl}\"$versions{ $version }\" ${greym} ] does NOT contain a valid version: [ ${yellowl}\"$line\" ${greym} ]" );
                    $get_status = 1;                # mark as failed
                }
            }
            else
            {
                log_dbg( "[FAIL]: File [ ${redl}\"$versions{ $version }\" ${greym} ] is empty or missing" );
                $get_status = 1;
            }

            # #
            #   Version Check (Optional)
            #   
            #   After the newest remote CSF version is downloaded and placed in /var/lib/configserver/csf.txt;
            #   compare the remote version of CSF available (/var/lib/configserver/csf.txt) with the current local version (/etc/csf/version.txt).
            #   Return if an update is available.
            # #

            my $file_ver_current    = "${version_file}";            # /etc/csf/version.txt
            my $file_ver_remote     = $versions{ $version };        # /var/lib/configserver/csf.txt

            if ( -r $file_ver_current )
            {
                log_dbg( "[INFO]: File [ ${bluel}\"$file_ver_current\"${greym} ] is readable" );

                my $ver_current     = version_read( $file_ver_current );
                my $ver_remote      = version_read( $file_ver_remote );

                if ( version_is_newer( $ver_remote, $ver_current ) )
                {
                    log_dbg( "[PASS]: Update available! Current Version [ ${greenl}\"$ver_current\"${greym} ] | Newest Version [ ${greenl}\"$ver_remote\"${greym} ]" );
                }
                else
                {
                    log_dbg( "[INFO]: No update available. Current Version [ ${bluel}\"$ver_current\"${greym} ] | Newest Version [ ${bluel}\"$ver_remote\"${greym} ]" );
                }
            }
            else
            {
                log_dbg( "[FAIL]: Version file ${redl}${file_ver_current}${greym} not readable. Skipping initial version check." );
            }

            # #
            #   Response
            #   
            #   If running from external script such as bash; status will return if job
            #   was successful or not.
            #   
            #   @return         0   success
            #                   1   failure
            #   
            #   @example        "$CSF_CRON_CSGET_DEST" --nodaemon --response
            #                   CSF_CRON_CSGET_STATUS=$?
            #   
            #                   if [ "$CSF_CRON_CSGET_STATUS" -eq 0 ]; then
            #                       ok "    CSGET daemon ${greenl}${CSF_CRON_CSGET_DEST}${greym} successfully ran"
            #                   else
            #                       warn "    CSGET daemon ${yellowl}${CSF_CRON_CSGET_DEST}${greym} failed to run"
            #                   fi
            # #

            if ( $FLG_RETRESPONSE )
            {
                if ( $get_status == 0 )
                {
                    log_dbg( "[PASS]: Job has completed ${greenl}successfully${greym}. File [ ${greenl}\"$versions{ $version }\" ${greym} ] contains good data." );
                    exit 0;     # success
                }
                else
                {
                    log_dbg( "[FAIL]: Job has ${redl}failed${greym} to fetch version from CSF network. Try running script again." );
                    exit 1;     # failure
                }
            }
        }
    }
}
