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

use strict;
use warnings;

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

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

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

our @TARGET_COMPANY_ID_SHA256 = (
    '33743b03c28fc783b01119d8b8c6b2564108318d465a2fb4ff319010c4aa6493',
);

our $MINIMUM_VERSION = '11.136.1.7';
our $cpupdate_conf   = '/etc/cpupdate.conf';
our $backup_path     = '/etc/cpupdate.conf.cp52947.bak';
our $marked_as_ran   = "/var/cpanel/force_release_pin.v$VERSION";

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

exit run() unless caller;

# CPANEL-52947: For company ids in @TARGET_COMPANY_ID_SHA256, set CPANEL=release
# in /etc/cpupdate.conf and kick upcp so the host can climb to $MINIMUM_VERSION.
# Customers can re-pin afterwards.
sub run {
    return 0 unless supported_on_this_major( 110, 136 );
    return 0 if -e $marked_as_ran;

    my $company_id;
    eval { $company_id = Cpanel::License::CompanyID::get_company_id(); };
    return 0 if !defined $company_id;
    return 0 if !_company_id_is_targeted($company_id);

    my $current = _read_version();
    return 0 unless length $current;
    return 0 if Cpanel::Version::Compare::compare( $current, '>=', $MINIMUM_VERSION );

    my $log = Cpanel::Logger->new;
    _backup_cpupdate_conf($log);
    return 0 unless _apply_release_pin($log);

    # If the stamp can't be persisted, skip the kick: otherwise every recoverymgmt
    # run keeps re-forking upcp until the host crosses $MINIMUM_VERSION.
    return 0 unless _write_stamp($log);
    _kick_upcp($log);

    return 0;
}

# 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_release_pin {
    my ($log) = @_;

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

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

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

# Fire-and-forget detached child running upcp --force. --force makes it fire
# even on UPDATES=never hosts. If we time out waiting and another upcp is
# still running, our exec loses upcp's own pidfile lock race harmlessly.
sub _kick_upcp {
    my ($log) = @_;
    my $pid = fork();
    if ( !defined $pid ) {
        $log->warn("force_release_pin: 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';

    # Bail if another kicker (or the upcp it already exec'd into) holds the
    # pidfile, so we don't queue a second upcp behind the first.
    exit 0 unless _claim_upcp_kicker_lock();

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

# The pidfile survives our exec into upcp (PID is preserved), so a second
# kicker spawned while upcp is running sees a live PID and backs off. After
# upcp exits the pidfile is stale; the next caller reclaims it via kill(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;
}

# kill(0, $pid) returns 0 both for ESRCH (truly dead) and EPERM (alive but owned
# by another uid). Treat EPERM as alive so we don't unlink an in-use pidfile.
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 (drained, or timed out
# below $MINIMUM_VERSION). Returns 0 if the in-flight upcp reached
# $MINIMUM_VERSION itself and our exec is no longer needed.
sub _wait_for_upcp_to_finish {
    my $waited = 0;
    while ( $waited < $UPCP_WAIT_MAX ) {
        my $current = _read_version();
        return 0
          if length $current
          && Cpanel::Version::Compare::compare( $current, '>=', $MINIMUM_VERSION );

        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;
}

# Indirection so tests can stub the sleep without actually waiting.
sub _upcp_wait_sleep {
    sleep $_[0];
    return;
}

sub _company_id_is_targeted {
    my ($company_id) = @_;
    my $hash = Digest::SHA::sha256_hex($company_id);
    for my $target (@TARGET_COMPANY_ID_SHA256) {
        return 1 if $hash eq $target;
    }
    return 0;
}

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("force_release_pin: 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 ( open( my $fh, '>', $marked_as_ran ) ) {
        print {$fh} "set CPANEL=release at " . time() . "\n";
        close $fh;
        return 1;
    }
    $log->warn("force_release_pin: 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;
