#!/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 autofixer2::run_security_update;

use strict;
use warnings;

BEGIN { unshift @INC, '/usr/local/cpanel'; }

use Cpanel::Update::Config   ();
use Cpanel::Version::Compare ();
use Cpanel::Logger           ();
use Cpanel::ProcessCheck     ();
use Fcntl                    ();
use File::Copy               ();
use POSIX                    ();

# Bump $VERSION to force a re-run on already-stamped hosts.
our $VERSION = 12;

# Map each major to the minimum fixed build for that major.  For majors
# 110-136, hosts whose exact major is not listed here will be upgraded to
# the nearest higher major that does have a fix.  Major 86 is exact-match
# only.  Majors 88-108 sweep up to 110; majors below 88 (other than 86)
# are silently skipped.
our %FIXED_VERSIONS = (
    86  => '11.86.0.41',
    110 => '11.110.0.97',
    118 => '11.118.0.63',
    126 => '11.126.0.54',
    130 => '11.130.0.19',
    132 => '11.132.0.29',
    134 => '11.134.0.20',
    136 => '11.136.0.5',
);

# Map each major to the tier name for the CPANEL= line in cpupdate.conf.
# Using tier names (not 4-part build strings) lets the server track
# future updates on that tier after it climbs past the fixed build.
our %TIER_MAP = (
    86  => '11.86',
    110 => '11.110',
    118 => '11.118',
    126 => '11.126',
    130 => '11.130',
    132 => '11.132',
    134 => 'release',
    136 => 'current',
);

our $cpupdate_conf    = '/etc/cpupdate.conf';
our $backup_path      = '/etc/cpupdate.conf.cp52965.bak';
our $marked_as_ran    = "/var/cpanel/run_security_update.v$VERSION";
our $server_type_path = '/usr/local/cpanel/server.type';

# Stamp file is treated as a retry window, not a "done" marker: while the
# stamp is younger than this, run_security_update keeps re-attempting on
# every recoverymgmt pass; once the stamp ages past this, we give up.
our $STAMP_RETRY_WINDOW = 7 * 86400;

our $UPCP_WAIT_INTERVAL  = 60;
our $UPCP_WAIT_MAX       = 3600;
our $upcp_kicker_pidfile = '/var/run/run_security_update.upcp.pid';

# Alt-* RPMs that CL6 / C6 hosts need on disk before upcp can build a
# working SSL/XML stack to climb to 11.110.  Order matters: openssl ->
# libssh2 -> libcurlssl -> libxml2.
our $ALT_LIBS_BASE_URL = 'https://httpupdate.cpanel.net/cpanelsync/autofixer_rpms';
our @ALT_LIBS          = (
    'alt-openssl-libs-1.0.2k-2.el6.cloudlinux.10.x86_64.rpm',
    'alt-libssh2-1.8.0-2.el6.x86_64.rpm',
    'alt-libcurlssl-7.62.0-1.el6.x86_64.rpm',
    'alt-libxml2-2.9.7-2.el6.x86_64.rpm',
);

# Install the set of RPMs from /cpanelsync/autofixer_rpms.  CL6's rpm
# can't fetch URLs itself, so wget the file into a fixed staging dir
# under /root, install it, and unlink it.  At the end the dir is
# rmdir'd (non-recursively, silently); if anything is still inside it
# the rmdir simply fails and the dir is left in place.
our $ALT_LIBS_STAGING_DIR = '/root/run_security_update_alt_libs';

exit run() unless caller;

# CPANEL-52965: Set CPANEL to the correct tier in /etc/cpupdate.conf and
# kick upcp so the host climbs to the fixed build.  For majors 110-136,
# the host may be upgraded across major boundaries to the nearest fixed
# major.  Major 86 is exact-match only.  Majors 88-108 sweep up to 110;
# other majors below 88 are skipped.  WP2 servers are skipped.  C6/CL6
# hosts get the alt-* libraries pre-staged before upcp is kicked
# (CPANEL-53044).
sub run {
    _stop("out of supported range") unless supported_on_this_major( 86, 136 );
    _stop("WP2 server, skipping") if _is_wp2();

    # CentOS 6 (and any non-CL EL6) is out of scope -- only CloudLinux 6
    # is targeted by the EL6 special-casing below.
    _stop("EL6 host that is not CloudLinux 6, skipping") if _is_os_major_6() && !_is_cl6();

    # Stamp file is a retry window, not a done marker.  If it exists
    # and is < STAMP_RETRY_WINDOW old, keep re-attempting (without
    # bumping its mtime, so the window starts at the first attempt).
    # Once it ages past the window, give up.
    my $stamp_existed = 0;
    if ( -e $marked_as_ran ) {
        my $age = time() - ( stat _ )[9];
        _stop("stamp file is older than the retry window") if $age >= $STAMP_RETRY_WINDOW;
        $stamp_existed = 1;
    }

    my $current = _read_version();
    _stop("could not read current cPanel version") unless length $current;

    my $major = _extract_major($current);
    _stop("could not extract major from version $current") unless defined $major;

    my $target_major = _resolve_fixed_major($major);
    _stop("no upgrade target for major $major") unless defined $target_major;

    my $fixed = $FIXED_VERSIONS{$target_major};
    _stop("already at or above fixed version $fixed for major $target_major")
      if Cpanel::Version::Compare::compare( $current, '>=', $fixed );

    my $tier = $TIER_MAP{$target_major};
    _stop("no tier mapping for major $target_major") unless defined $tier;

    # CL6 hosts targeting 110 must use the cl6110 tier (CPANEL-52984).
    $tier = 'cl6110' if $tier eq '11.110' && _is_cl6();

    my $log = Cpanel::Logger->new;

    # On CloudLinux 6, enable EPEL and pre-stage alt-* libraries on a
    # best-effort basis before starting upcp.
    if ( _is_cl6() ) {
        _enable_epel($log);
        _install_alt_libs($log);
    }

    _backup_cpupdate_conf($log);
    _stop("could not apply tier $tier to $cpupdate_conf") unless _apply_tier( $log, $tier );
    if ( !$stamp_existed ) {
        _stop("could not write stamp file $marked_as_ran") unless _write_stamp($log);
    }
    _kick_upcp( $log, $fixed );

    return 0;
}

sub _stop {
    my ($msg) = @_;
    Cpanel::Logger->new->info($msg);
    exit 0;
}

sub _extract_major {
    my ($version) = @_;
    my ($major)   = $version =~ /^[0-9]+\.([0-9]+)/;
    return $major;
}

# For majors 110-136: return the exact major if it has a fix, otherwise
# the smallest fixed major above it (capped at 136).  Major 86 is
# exact-match only.  Majors 88-108 sweep up to 110.  Majors below 88
# (other than 86) return undef (no upgrade).
sub _resolve_fixed_major {
    my ($major) = @_;

    return $major if exists $FIXED_VERSIONS{$major};

    # Sweep majors 88-108 up to 110.
    return 110 if $major >= 88 && $major <= 108;

    # Cross major boundaries within the 110-136 range.
    return undef if $major < 86 || $major > 136;

    for my $candidate ( sort { $a <=> $b } keys %FIXED_VERSIONS ) {
        next if $candidate < $major;
        next if $candidate > 136;
        return $candidate;
    }
    return undef;
}

sub _is_wp2 {
    my $type = readlink($server_type_path) // '';
    return $type eq 'wp2';
}

sub _is_os_major_6 {
    my $line = _read_release_line() // return 0;
    return $line =~ /\s6\.\d+/ ? 1 : 0;
}

# CloudLinux 6 specifically.  /etc/cloudlinux-release does not exist
# on every CL6 host, so identify by reading the first line of
# /etc/redhat-release (or /etc/system-release) and looking for both
# "CloudLinux" and a 6.x version string.
sub _is_cl6 {
    my $line = _read_release_line() // return 0;
    return ( $line =~ /CloudLinux/ && $line =~ /\s6\.\d+/ ) ? 1 : 0;
}

sub _read_release_line {
    for my $path ( '/etc/redhat-release', '/etc/system-release', '/etc/cloudlinux-release' ) {
        open( my $fh, '<', $path ) or next;
        my $line = readline($fh);
        close $fh;
        return $line if defined $line;
    }
    return undef;
}

# Best-effort EPEL enable on CloudLinux 6.  Failures are logged but
# never block the upcp kick.
sub _enable_epel {
    my ($log) = @_;
    if ( system( 'yum', '-y', 'install', 'epel-release' ) != 0 ) {
        $log->warn("run_security_update: 'yum install epel-release' failed (best-effort, continuing)");
    }
    if ( system( 'yum-config-manager', '--enable', 'epel' ) != 0 ) {
        $log->warn("run_security_update: 'yum-config-manager --enable epel' failed (best-effort, continuing)");
    }
    return;
}

sub _install_alt_libs {
    my ($log) = @_;

    if ( !-d $ALT_LIBS_STAGING_DIR && !mkdir $ALT_LIBS_STAGING_DIR, 0700 ) {
        $log->warn("run_security_update: failed to create $ALT_LIBS_STAGING_DIR: $!");
        return;
    }

    for my $file (@ALT_LIBS) {
        my $url   = "$ALT_LIBS_BASE_URL/$file";
        my $local = "$ALT_LIBS_STAGING_DIR/$file";

        $log->info("run_security_update: downloading $url");
        if ( system( 'wget', '-q', '-O', $local, $url ) != 0 || !-s $local ) {
            $log->warn("run_security_update: wget $url failed (best-effort, continuing)");
            unlink $local;
            next;
        }

        $log->info("run_security_update: installing $local");
        if ( system( 'rpm', '-Uvh', '--force', '--nodeps', $local ) != 0 ) {
            $log->warn("run_security_update: rpm -Uvh --force --nodeps $local failed (best-effort, continuing)");
        }

        unlink $local;
    }
    rmdir $ALT_LIBS_STAGING_DIR;
    return;
}

# Returns 0 if save() or the post-save read-back disagrees, so the caller
# leaves the stamp unwritten and a future invocation can retry.
sub _apply_tier {
    my ( $log, $tier ) = @_;

    my %cfg = Cpanel::Update::Config::load();
    $cfg{'CPANEL'} = $tier;
    if ( !Cpanel::Update::Config::save( \%cfg ) ) {
        $log->warn("run_security_update: Cpanel::Update::Config::save returned false; leaving stamp unset");
        return 0;
    }

    my %verify = Cpanel::Update::Config::load();
    if ( ( $verify{'CPANEL'} // '' ) ne $tier ) {
        $log->warn("run_security_update: post-save read-back does not show CPANEL=$tier; leaving stamp unset");
        return 0;
    }

    $log->info("run_security_update: set CPANEL=$tier in $cpupdate_conf");
    return 1;
}

# Fire-and-forget detached child running upcp --force.
sub _kick_upcp {
    my ( $log, $fixed ) = @_;
    my $pid = fork();
    if ( !defined $pid ) {
        $log->warn("run_security_update: fork() failed, not invoking upcp: $!");
        return;
    }
    return if $pid;

    chdir('/');
    POSIX::setsid();
    for ( 3 .. 1024 ) {
        POSIX::close($_);
    }
    open STDIN,  '<', '/dev/null';
    open STDOUT, '>', '/dev/null';
    open STDERR, '>', '/dev/null';

    exit 0 unless _claim_upcp_kicker_lock();

    if ( _wait_for_upcp_to_finish($fixed) ) {
        exec '/usr/local/cpanel/scripts/upcp', '--force';
        exit 1;
    }
    exit 0;
}

sub _claim_upcp_kicker_lock {
    for ( 1, 2 ) {
        if (
            sysopen(
                my $fh, $upcp_kicker_pidfile,
                Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL() | Fcntl::O_NOFOLLOW()
            )
        ) {
            print {$fh} "$$\n";
            close $fh;
            return 1;
        }
        my $existing = _read_kicker_pidfile();
        return 0 if defined $existing && $existing =~ /^\d+$/ && _pid_is_alive($existing);
        unlink $upcp_kicker_pidfile or return 0;
    }
    return 0;
}

sub _pid_is_alive {
    my ($pid) = @_;
    return 1 if kill( 0, $pid );
    return 1 if $! == POSIX::EPERM();
    return 0;
}

sub _read_kicker_pidfile {
    sysopen( my $fh, $upcp_kicker_pidfile, Fcntl::O_RDONLY() | Fcntl::O_NOFOLLOW() ) or return;
    my $pid = readline($fh);
    close $fh;
    return unless defined $pid;
    chomp $pid;
    return $pid;
}

# Returns 1 if the caller should still exec upcp.
sub _wait_for_upcp_to_finish {
    my ($fixed) = @_;
    my $waited = 0;
    while ( $waited < $UPCP_WAIT_MAX ) {
        my $current = _read_version();
        return 0
          if length $current
          && Cpanel::Version::Compare::compare( $current, '>=', $fixed );

        my %pids = Cpanel::ProcessCheck::previouspids( 'process' => 'upcp' );
        delete $pids{$$};
        return 1 if !scalar keys %pids;

        _upcp_wait_sleep($UPCP_WAIT_INTERVAL);
        $waited += $UPCP_WAIT_INTERVAL;
    }
    return 1;
}

sub _upcp_wait_sleep {
    sleep $_[0];
    return;
}

sub _backup_cpupdate_conf {
    my ($log) = @_;
    return if -e $backup_path;
    return if !-e $cpupdate_conf;
    if ( !File::Copy::copy( $cpupdate_conf, $backup_path ) ) {
        $log->warn("run_security_update: failed to back up $cpupdate_conf to $backup_path: $!");
        return;
    }
    if ( my $mode = ( stat $cpupdate_conf )[2] ) {
        chmod( $mode & 07777, $backup_path );
    }
    return;
}

sub _write_stamp {
    my ($log) = @_;
    if ( sysopen( my $fh, $marked_as_ran, Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL() ) ) {
        print {$fh} "run_security_update ran at " . time() . "\n";
        close $fh;
        return 1;
    }
    $log->warn("run_security_update: failed to write stamp $marked_as_ran: $!");
    return 0;
}

sub _read_version {
    my $full = _read_version_file();
    return '' unless length $full;
    return $full;
}

sub _read_version_file {
    open( my $fh, '<', '/usr/local/cpanel/version' ) or return '';
    my $version = readline($fh);
    close $fh;
    return '' unless defined $version;
    chomp $version;
    return $version;
}

sub supported_on_this_major {
    my ( $min_ver, $max_ver ) = @_;

    my $major = get_major_version();

    return 0 if $major < $min_ver;
    return 0 if $major > $max_ver;

    return 1;
}

sub get_major_version {
    my $full = _read_version_file();
    my ($major) = $full =~ /^[0-9]+\.([0-9]+)/;
    return $major || 30;
}

1;
