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

use strict;
use warnings;

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

use Cpanel::OS              ();
use Cpanel::SafeRun::Object ();
use Cpanel::Logger          ();

my @CPANEL_PLUGIN_PACKAGES = qw(
  cpanel-bannerx-plugin
  cpanel-comet-backup-plugin
  cpanel-koality-plugin
  cpanel-monitoring-agent-plugin
  cpanel-monitoring-plugin
  cpanel-nova-plugin
  cpanel-plugin-common
  cpanel-plugin-components
  cpanel-sitejet-plugin
  cpanel-socialbee-plugin
  cpanel-xovi-plugin
);

my $METADATA_TIMEOUT = 600;
my $UPDATE_TIMEOUT   = 1200;

# -----------------------------------------------------------------------------
# Hang-prevention options for apt-get on Ubuntu / Debian
# -----------------------------------------------------------------------------
# apt-get is the script-stable interface, but on its own it can still block
# indefinitely waiting for a tty. Each option below addresses a specific
# real-world hang mode we have observed in the field.
# -----------------------------------------------------------------------------

# DPkg::Lock::Timeout=60
#
# When unattended-upgrades, packagekitd, or another apt invocation is mid-run,
# apt-get blocks waiting for /var/lib/dpkg/lock-frontend with no upper bound —
# our own SafeRun::Object timeout (1200 s for the install) is the only thing
# that eventually kills it, and the 1200 s of held lock makes other admin
# tooling on the host wait too. Setting this option makes apt-get exit
# cleanly after 60 s with a clear lock-contention error which we can log,
# instead of silently blocking. 60 s is enough to ride out brief overlaps
# with other apt activity but short enough that a stuck system surfaces
# the problem the same nightly cycle.
my @APT_LOCK_OPTS = ( '-o', 'DPkg::Lock::Timeout=60' );

# Dpkg::Options::=--force-confdef and Dpkg::Options::=--force-confold
#
# `apt-get -y` answers "yes" to apt-level prompts but does NOT cover dpkg's
# config-file-conflict prompt:
#
#     Configuration file '/etc/<file>' has changed.
#     What would you like to do about it?  [default=N]
#
# That prompt fires when an upgraded package ships a new default for a
# config file the operator has locally edited; with no tty connected,
# dpkg waits on /dev/tty forever. The two options together tell dpkg:
#   --force-confdef  : if there is a maintainer-defined default action for
#                      the conflict, take it without asking
#   --force-confold  : otherwise, keep the operator's existing config file
#                      and write the new one to <file>.dpkg-dist
# This is the conservative choice: we never silently overwrite an
# operator's customised config, and we never block on the prompt.
my @APT_KEEP_LOCAL_CONFIG_OPTS = (
    '-o', 'Dpkg::Options::=--force-confdef',
    '-o', 'Dpkg::Options::=--force-confold',
);

# _apt_safe_env: before_exec callback for apt-get invocations.
#
# Cpanel::SafeRun::Object calls Cpanel::Env::clean_env in the child by
# default (Cpanel/SafeRun/Object.pm:158, 481), so we cannot rely on the
# parent's environment being inherited. The before_exec callback fires
# AFTER clean_env in the forked child and BEFORE exec(), which is the
# correct seam to set environment that apt-get / dpkg / hooks will see.
sub _apt_safe_env {

    # DEBIAN_FRONTEND=noninteractive
    #
    # Tells dpkg postinst scripts to use the non-interactive debconf
    # frontend. The default frontend opens /dev/tty for input; in our
    # context there is no tty, so a postinst that calls debconf will
    # block forever. noninteractive forces debconf to use scripted
    # defaults instead of prompting.
    $ENV{DEBIAN_FRONTEND} = 'noninteractive';

    # NEEDRESTART_MODE=a (auto) and NEEDRESTART_SUSPEND=1
    #
    # On Ubuntu 22.04+ the needrestart package hooks into apt and runs
    # after package operations. In some configurations it prompts the
    # operator to confirm which services should be restarted. Mode "a"
    # answers automatically; SUSPEND=1 is the belt-and-braces backup
    # that disables needrestart entirely if it is configured to ignore
    # the mode setting. Either alone would suffice on a stock install;
    # we set both to be robust against odd /etc/needrestart/needrestart.conf
    # customisations on customer machines.
    $ENV{NEEDRESTART_MODE}    = 'a';
    $ENV{NEEDRESTART_SUSPEND} = '1';
    return;
}

our $VERBOSE = 0;

exit run(@ARGV) unless caller;

sub run {
    my (@args) = @_;
    $VERBOSE = scalar grep { $_ eq '--verbose' || $_ eq '-v' } @args;
    local $| = 1 if $VERBOSE;    # autoflush so progress appears in real time on hang

    return 0 unless supported_on_this_major( 110, 136 );

    my $logger          = Cpanel::Logger->new;
    my $package_manager = _get_package_manager();
    _verbose("package manager: $package_manager");

    _verbose('checking for installed cpanel-plugin packages');
    if ( !_any_packages_installed($package_manager) ) {
        _verbose('no cpanel-plugin packages installed; nothing to do');
        return 0;
    }

    _refresh_metadata( $logger, $package_manager );
    _update_packages( $logger, $package_manager );
    _verbose('done');

    return 0;
}

sub _verbose {
    return unless $VERBOSE;
    print "\nupdate_cpanel_plugins: $_[0]\n";
    return;
}

# In verbose mode, hand SafeRun::Object real STDOUT/STDERR filehandles so the
# child writes go directly to the operator's terminal in real time. Without
# this, output is captured silently until the call returns.
# NOTE: Cpanel::SafeRun::Object throws if stdout()/stderr() are called on the
# resulting object when custom filehandles were provided — callers must not
# read $run->stdout()/stderr() in verbose mode.
sub _stream_io_opts {
    return $VERBOSE ? ( 'stdout' => \*STDOUT, 'stderr' => \*STDERR ) : ();
}

sub _captured_output {
    my ($run) = @_;
    return '' if $VERBOSE;    # already streamed live; calling stdout()/stderr() would throw
    return $run->stdout() . $run->stderr();
}

sub _any_packages_installed {
    my ($package_manager) = @_;
    for my $package (@CPANEL_PLUGIN_PACKAGES) {
        my ( $program, @args ) =
          $package_manager eq 'apt'
          ? ( 'dpkg-query', '-W', $package )
          : ( 'rpm', '-q', $package );
        my $run = Cpanel::SafeRun::Object->new(
            'program' => $program,
            'args'    => \@args,
        );
        return 1 unless $run->CHILD_ERROR();
    }
    return 0;
}

sub _get_package_manager {
    return 'apt' unless Cpanel::OS::is_rpm_based();
    return 'dnf' if _dnf_available();
    return 'yum';
}

sub _dnf_available {
    return -x '/usr/bin/dnf';
}

sub _run_error_reason {
    my ($run) = @_;
    return 'timed out after ' . $run->timed_out() . 's' if $run->timed_out();
    return 'exit ' . ( $run->CHILD_ERROR() >> 8 );
}

sub _refresh_metadata {
    my ( $logger, $package_manager ) = @_;
    if ( $package_manager eq 'apt' ) {
        _verbose("refreshing package cache: apt-get update (timeout ${METADATA_TIMEOUT}s)");
        my $run = Cpanel::SafeRun::Object->new(
            'program'     => 'apt-get',
            'args'        => [ @APT_LOCK_OPTS, 'update' ],
            'timeout'     => $METADATA_TIMEOUT,
            'before_exec' => \&_apt_safe_env,
            _stream_io_opts(),
        );
        if ( $run->CHILD_ERROR() ) {
            my $out = _captured_output($run);
            $logger->warn( 'update_cpanel_plugins: package cache update failed (' . _run_error_reason($run) . "): $out" );
            print "update_cpanel_plugins: failed to refresh apt package cache (apt-get update); see /usr/local/cpanel/logs/error_log for details\n";
        }
    }
    else {
        _verbose("cleaning package cache: $package_manager clean all (timeout ${METADATA_TIMEOUT}s)");
        my $run_clean = Cpanel::SafeRun::Object->new(
            'program' => $package_manager,
            'args'    => [ 'clean', 'all' ],
            'timeout' => $METADATA_TIMEOUT,
            _stream_io_opts(),
        );
        if ( $run_clean->CHILD_ERROR() ) {
            my $out = _captured_output($run_clean);
            $logger->warn( 'update_cpanel_plugins: package cache clean failed (' . _run_error_reason($run_clean) . "): $out" );
            print "update_cpanel_plugins: failed to clean the package cache ($package_manager clean all); see /usr/local/cpanel/logs/error_log for details\n";
        }
        _verbose("rebuilding package cache: $package_manager makecache (timeout ${METADATA_TIMEOUT}s)");
        my $run_cache = Cpanel::SafeRun::Object->new(
            'program' => $package_manager,
            'args'    => ['makecache'],
            'timeout' => $METADATA_TIMEOUT,
            _stream_io_opts(),
        );
        if ( $run_cache->CHILD_ERROR() ) {
            my $out = _captured_output($run_cache);
            $logger->warn( 'update_cpanel_plugins: package cache refresh failed (' . _run_error_reason($run_cache) . "): $out" );
            print "update_cpanel_plugins: failed to rebuild the package cache ($package_manager makecache); see /usr/local/cpanel/logs/error_log for details\n";
        }
    }
    return;
}

sub _update_packages {
    my ( $logger, $package_manager ) = @_;

    my ( $program, $verb, @args, %extra );
    if ( $package_manager eq 'apt' ) {
        $program = 'apt-get';
        $verb    = 'install';
        @args    = ( @APT_LOCK_OPTS, @APT_KEEP_LOCAL_CONFIG_OPTS, $verb, '--only-upgrade', '-y', @CPANEL_PLUGIN_PACKAGES );
        $extra{before_exec} = \&_apt_safe_env;
    }
    else {
        $program = $package_manager;
        $verb    = 'update';

        # --disableexcludes=all intentionally overrides yum/dnf exclude directives (including
        # server-owner customisations) to ensure security-critical plugin updates are not
        # silently blocked.  There is no apt equivalent; holds/pins on DEB systems are respected.
        @args = ( $verb, '-y', '--disableexcludes=all', @CPANEL_PLUGIN_PACKAGES );
    }

    _verbose( "upgrading " . scalar(@CPANEL_PLUGIN_PACKAGES) . " plugin packages: $program $verb (timeout ${UPDATE_TIMEOUT}s)" );
    my $run = Cpanel::SafeRun::Object->new(
        'program' => $program,
        'args'    => \@args,
        'timeout' => $UPDATE_TIMEOUT,
        %extra,
        _stream_io_opts(),
    );
    if ( $run->CHILD_ERROR() ) {
        my $out = _captured_output($run);
        $logger->warn( 'update_cpanel_plugins: cpanel-plugins package update failed (' . _run_error_reason($run) . "): $out" );
        print "update_cpanel_plugins: failed to update cpanel-plugins packages; see /usr/local/cpanel/logs/error_log for details\n";
    }
    return;
}

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 $major_version;
    if ( open( my $fh, '<', '/usr/local/cpanel/version' ) ) {
        my $full_version = <$fh>;
        close($fh);
        if ( length $full_version ) {
            chomp $full_version;
            ($major_version) = $full_version =~ /^[0-9]+\.([0-9]+)/;
        }
    }
    return $major_version || 30;
}

1;
