Mini Shell

Direktori : /scripts/
Upload File :
Current File : //scripts/ea-nginx

#!/usr/local/cpanel/3rdparty/bin/perl
# cpanel - ea-nginx                                Copyright 2020 cPanel, L.L.C.
#                                                           All rights Reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited

use strict;
use warnings;

package scripts::ea_nginx;

use Cpanel::Apache::TLS                      ();
use Cpanel::Config::LoadCpConf               ();
use Cpanel::Config::LoadUserDomains          ();
use Cpanel::Config::LoadWwwAcctConf          ();
use Cpanel::Config::userdata                 ();
use Cpanel::Config::userdata::PassengerApps  ();
use Cpanel::DIp::IsDedicated                 ();
use Cpanel::DomainIp                         ();
use Cpanel::DomainLookup::DocRoot            ();
use Cpanel::EA4::Conf                        ();
use Cpanel::EA4::Conf::Tiny                  ();
use Cpanel::FileGuard                        ();
use Cpanel::FileUtils::Move                  ();
use Cpanel::FileUtils::TouchFile             ();
use Cpanel::HTTP::Client                     ();
use Cpanel::HttpUtils::ApRestart::BgSafe     ();
use Cpanel::HttpUtils::Vhosts::PrimaryReader ();
use Cpanel::Hostname                         ();
use Cpanel::JSON                             ();
use Cpanel::Kernel                           ();
use Cpanel::NAT                              ();
use Cpanel::OpenSSL                          ();
use Cpanel::PHP::Config                      ();
use Cpanel::PHPFPM::Get                      ();
use Cpanel::PwCache                          ();
use Cpanel::Rand::Get                        ();
use Cpanel::SafeRun::Object                  ();
use Cpanel::Transaction::File::JSON          ();
use Cpanel::Validate::URL                    ();
use Cpanel::Validate::Username               ();
use Cpanel::WildcardDomain                   ();
use File::Glob                               ();
use File::Path::Tiny                         ();
use IO::Callback                             ();
use String::UnicodeUTF8                      ();
use Template                                 ();
use Whostmgr::TweakSettings                  ();

use Path::Tiny;
use MCE::Loop;

our $var_cache_ea_nginx                = '/var/cache/ea-nginx';
our $var_cpanel_userdata               = '/var/cpanel/userdata';
our $etc_nginx                         = '/etc/nginx';
our $etc_ea_nginx                      = "$etc_nginx/ea-nginx";
our $settings_file                     = "$etc_ea_nginx/settings.json";
our $cache_file                        = "$etc_ea_nginx/cache.json";
our $proxy_header_file                 = "$etc_ea_nginx/cpanel_localhost_header.json";
our $serial_touch_file                 = "$etc_ea_nginx/serial_config_mode";
our $skip_clearing_cache_touch_file    = "$etc_ea_nginx/do_not_clear_cache_on_reload_i_understand_the_risks"; #This is highly discouraged, we do not want to document this functionality. See ZC-11741
our $wp_toolkit_bin                    = '/usr/local/bin/wp-toolkit';
our $wordpress_cache_dir               = '/etc/nginx/wordpress_info_cache';
our $disable_wordpress_info_cache_file = '/var/cpanel/nginx_disable_wordpress_info_cache';
our $cpanel_proxy_file                 = '/etc/nginx/conf.d/includes-optional/cpanel-proxy-xt.conf';
our $custom_settings_dir               = '/var/nginx/ea-nginx';
our $piped_module_conf                 = '/etc/nginx/conf.d/modules/ngx_http_pipelog_module.conf';
our $user_id_file                      = '/etc/cpanel/ea4/option-flags/set-USER_ID';

our $default_server_names_hash_bucket_size = 128;

# Used to validate certificates
my $openssl_obj;

my $nginxroot = "/etc/nginx";

my $cmds = {
    config => {
        code     => \&config,
        clue     => "config [--all|--global|<user>] [--no-reload] [--serial]",
        abstract => 'Build nginx configuration for users',
        help     => "Build nginx configuration for one or all users.\n\t--global will only rebuild global configurations and executes anything in /etc/nginx/ea-nginx/config-scripts/global/\n\t--all implies --global and re-does configuration for all users\n\t--serial used with --all will build the config synchronously.  Otherwise, the script default is to build the config in parallel",
    },
    reload => {
        code     => \&reload,
        clue     => "reload",
        abstract => 'reload nginx configuration',
        help     => "reload nginx configuration - a convenience for `restartsrv_nginx reload` (useful after multiple changes w/ --no-reload)",
    },
    remove => {
        code     => \&remove,
        clue     => "remove <user> [--no-reload]",
        abstract => "remove a given user’s nginx configuration",
        help     => "Remove the given user’s nginx configuration. Only does the generated conf.d/user/<user>.conf, does not touch the user customization directory conf.d/users/<user>/",
    },
    clear_cache => {
        code     => \&clear_cache_cmd,
        clue     => "clear_cache [--all|<user1> <user2> ...]",
        abstract => 'Clear the cache for a user, some users, or all users.',
        help     => 'Clears a cache or caches. Pass the users you want to clear or none for all users.',
    },
    cache => {
        code     => \&cache_config,
        clue     => "cache [--system|<user>] [--no-rebuild] [[--reset|--enabled=[1|0]]]",
        abstract => "Manage cache configuration at the system or user level",
        help     => "View, configure, reset NGINX caching at the system or user level\n\n\t--no-rebuild will make it skip the config regeneration and NGINX reload necessary to make the changes take effect\n\n\t--reset w/ --system will reset the system level config to the defaults\n\t--reset w/ a user will remove the user’s config effectively resetting it to the system level config\n",
    },
};

my $hint_blurb = "Usage: `$0 {command} …`.\n\tThis tool supports the following commands:";
my $opts       = {
    'help:pre_hint'  => $hint_blurb,
    'help:pre_help'  => "Various ea-nginx related admin utilities\n\n$hint_blurb",
    default_commands => "help",
    alias            => { conf => "config" },
    'help:post_help' => "More information on `ea-nginx` (what works and what does not, how to customize configuration, etc)\n  can be found at https://go.cpanel.net/ea-nginx.",
};

# Cache for proxied domains
my %apache2nginx_proxied_domains;

sub _load_apache2nginx_proxied_domains {
    my $server_json_path = '/var/lib/apache2nginx/server.json';

    if (-e $server_json_path) {
        my $data;
        $data = Cpanel::JSON::LoadFile($server_json_path);

        foreach my $domain (keys %{$data}) {
            # Check if 'proxy_reason' exists, is defined, and not empty
            if (exists $data->{$domain}{'proxy_reason'} && defined $data->{$domain}{'proxy_reason'} && $data->{$domain}{'proxy_reason'} ne '') {
                $apache2nginx_proxied_domains{$domain} = 1;
                print "Domain '$domain' marked as proxied due to proxy_reason.\n";
            }
            else {
                print "Domain '$domain' does not have a valid proxy_reason; not marked as proxied.\n";
            }
	}
    }
    else {
	warn "server.json not found at $server_json_path. No domains will be excluded from redirects based on proxy_reason.\n";
    }
}

# Initialize apache2nginx_proxied_domains at script start
_load_apache2nginx_proxied_domains();


run(@ARGV) if !caller;

=encoding utf-8

=head1 NAME

scripts::ea_nginx

=head1 SYNOPSIS

    ea-nginx [ cache|clear_cache|config|reload|remove|help ]

    NOTE: Execute the script with --help for more details or go to https://go.cpanel.net/ea-nginx
          Can also execute 'ea-nginx [help|hint] subcommand' for targeted details about a subcommand

=head1 DESCRIPTION

This script provides various ea-nginx related admin utilities

=over

=item cache subcommand

View, configure, reset NGINX caching at the system or user level

=item clear_cache subcommand

Clears a cache or caches. Pass the users you want to clear or none for all users

=item config subcommand

Build nginx configuration for one or all users

=item reload subcommand

Reload nginx configuration

=item remove subcommand

Remove the given user’s nginx configuration

=back

=cut

sub run {
    my (@argv) = @_;
    die "This script should only be called as root\n" if $> != 0;

    local $ENV{TERM} = $ENV{TERM} || "xterm-256color";    # non-CLI modulino avoid needless: Cannot find termcap: TERM not set at …/Term/ReadLine.pm line 373.
    require App::CmdDispatch;
    import App::CmdDispatch;

    # need to have App::CmdDispatch do this automatically see CPANEL-22328
    if ( @argv && grep { defined && m/\A\-\-help\z/ } @argv ) {
        App::CmdDispatch->new( $cmds, $opts )->help();
        exit(0);
    }

    my $orig_command_hint = \&App::CmdDispatch::command_hint;
    no warnings "redefine";
    local *App::CmdDispatch::command_hint = sub {
        $orig_command_hint->(@_);
        exit(1);
    };
    no warnings 'once';
    require App::CmdDispatch::IO;
    local *App::CmdDispatch::IO::print = sub {
        shift;
        if ( ref($@) && $@ =~ m/^App::CmdDispatch::Exception/ ) {
            CORE::print STDERR @_;
            return;
        }
        CORE::print(@_);
        return;
    };
    use warnings 'once';

    # ^^^ /need to have App::CmdDispatch do this automatically see CPANEL-22328

    # Enforce acquiring a lock before continuing so that two competing processes
    # do not stomp on each other
    my $lock_dir = '/var/cpanel/locks';
    mkdir( $lock_dir, 0700 ) unless -e $lock_dir;

    my $nginx_config_lock = Cpanel::FileGuard->new("$lock_dir/ea-nginx-config-in-progress");

    # allows customers to set custom configs before ea-nginx is installed
    _update_for_custom_configs();

    return App::CmdDispatch->new( $cmds, $opts )->run(@argv);
}

################
#### commands ##
################

=head2 remove()

Remove the given user’s nginx configuration

=over

=item user - required

A valid cPanel username

=item --no-reload - optional

Whether to reload nginx and clear the cache after removing the user

=back

=cut

sub remove {
    my ( $app, $user, @args ) = @_;

    _validate_user_arg( $app, $user );

    my $file = "$nginxroot/conf.d/users/$user.conf";
    my $dir  = "$nginxroot/conf.d/users/$user/";

    if ( -f $file ) {
        print "Removing $file …\n";
        unlink $file;
        if ( -f $file ) {
            die "Unable to remove $file\n";
        }
        unless ( grep { $_ eq '--no-reload' } @args ) {
            _reload();
            clear_cache($user);
        }

    }
    else {
        print "$file is already removed or never existed.\n";
    }

    if ( -d $dir ) {
        warn "Customization path $dir exists, you will need to manually move/remove/reconfigure that.\n";
    }
    else {
        print "Customization path $dir does not exist. You are all set!\n";
    }

    return;
}

=head2 config()

Build nginx configuration for one or all users.

=over

=item --all

--all implies --global and re-does configuration for all users

=item --global

--global will only rebuild global configurations and executes anything in
/etc/nginx/ea-nginx/config-scripts/global/

=item user

A valid cPanel username

=back

=over

=item --no-reload - optional

Whether to reload nginx and clear the cache after making config changes

=item --serial - optional

--serial used with --all will build the config synchronously.  Otherwise, the
script default is to build the config in parallel

=back

=cut

sub config {
    my ( $app, $user, @args ) = @_;

    my $reload = grep( { $_ eq '--no-reload' } @args ) ? 0 : 1;

    my $use_serial_mode = grep( { $_ eq '--serial' } @args ) ? 1 : 0;
    $use_serial_mode = 1 if -e $serial_touch_file;

    if ( $user eq '--all' || $user eq '--global' ) {

        _write_global_cpanel_localhost();
        _write_global_logging();
        _write_global_passenger();
        _write_global_ea_nginx();
        _write_global_nginx_conf();
        _write_global_default();
        _write_global_cpanel_proxy_non_ssl();
        _do_other_global_config();

        my $errors = {};
        if ( $user eq '--all' ) {
            _populate_wordpress_cache( { all => 1 } );
            my $global_config_data = _get_global_config_data();
            mkdir "/etc/nginx/conf.d/users/";
            unlink( File::Glob::bsd_glob("/etc/nginx/conf.d/users/*.conf") );
            $errors =
              $use_serial_mode
              ? _update_user_configs_in_serial_mode($global_config_data)
              : _update_user_configs_in_parallel_mode($global_config_data);
        }

        if ($reload) {
            _reload();
            clear_cache() if !-e $skip_clearing_cache_touch_file;
        }

        if ( keys %$errors ) {
            my $err = "";
            for my $usr ( sort keys %$errors ) {
                $err .= "\tUser “$usr”: $errors->{$usr}\n\n";
            }

            die "The following user related errors need attention:\n$err";
        }
    }
    else {
        _validate_user_arg( $app, $user );
        mkdir "/etc/nginx/conf.d/users/";

        # Settings in this file can be affected when a new user is added
        _write_global_ea_nginx();

        _populate_wordpress_cache( { user => $user } );
        my $global_config_data = _get_global_config_data();
        _write_user_conf( $user, $global_config_data );
        if ($reload) {
            _reload("/etc/nginx/conf.d/users/$user.conf");
            clear_cache($user) if !-e $skip_clearing_cache_touch_file;
        }
    }

    return 1;
}

=head2 cache_config()

View, configure, or reset NGINX caching at the system or user level

=over

=item --system

View, configure, or reset the global defaults

=item user

View, configure, or reset the NGINX caching config for the given user

=back

=over

=item --no-rebuild - optional

Will make it skip the config regeneration and NGINX reload necessary to make the
changes take effect

=item --reset - optional

=over 2

=item with --system

Will reset the system level config to the defaults

=item with a user

Will remove the user’s config effectively resetting it to the system level
config

=back

=item --enabled=[1|0] - optional

=over 2

=item with --system

Will enable/disable caching for users using the global default

=item with user

Will enable/disable caching for the user

=back

=back

=cut

sub cache_config {
    my ( $app, $which, @flags ) = @_;
    die "First argument to `cache` must be `--system` or a cPanel user name\n" if !length $which;

    my $file       = $cache_file;
    my $config_arg = "--all";

    if ( $which ne "--system" ) {
        _validate_user_arg( $app, $which );
        $file       = $var_cpanel_userdata . "/$which/nginx-cache.json";
        $config_arg = $which;
    }

    if ( !@flags ) {
        print eval { path($file)->slurp } || "{}\n";
    }
    else {
        require Getopt::Param::Tiny;
        my $prm = Getopt::Param::Tiny->new(
            {
                array_ref    => \@flags,
                known_only   => [qw(reset enabled no-rebuild)],
                help_coderef => sub { $app->help(); exit(1) },
                validate     => sub {
                    my ($prm) = @_;
                    my @given = grep { $_ ne "reset" && $_ ne "no-rebuild" } @{ $prm->param() };

                    die "--reset does not make sense w/ other flags (besides --no-rebuild)\n" if $prm->param('reset') && @given;

                    return 1;
                },
            }
        );

        if ( $prm->param('reset') ) {
            if ( $which eq "--system" ) {
                my %caching_defaults = caching_defaults();
                _jsonify_caching_booleans( \%caching_defaults );
                _write_json( $file, \%caching_defaults );
            }
            else {
                unlink $file;
            }
        }
        else {
            my $conf_hr = eval { Cpanel::JSON::LoadFile($file) } || {};

            # always set it since its the only option until ZC-8549
            $conf_hr->{enabled} = _bool_param( enabled => scalar( $prm->param('enabled') ) );

            # ZC-8549 example:
            # my %given_params;
            # @given_params{ $prm->param() } = ();
            # for my $bool (qw(enabled x_cache_header logging)) {
            #    next if !exists $given_params{$bool}; # only set it if given
            #    $conf_hr->{$bool} = _bool_param( $bool => scalar($prm->param($bool)) );
            # }
            #
            # if (exists $given_params{zone_size}) {
            #    my $zone_size = $prm->param('zone_size');
            #    die "Invalid --zone_size, it must …\n" if … $zone_size;
            #    $conf_hr->{zone_size} = $zone_size;
            # }

            _jsonify_caching_booleans($conf_hr);
            _write_json( $file, $conf_hr );
        }

        config( $app, $config_arg ) unless $prm->param("no-rebuild");
    }

    return 1;

}

=head2 clear_cache_cmd()

Clears a cache or caches. Pass the users you want to clear or none for all
users.

=over

=item --all - optional

Clear the cache for all users

=item user1 user2 user3 …

A list of users to clear the cache for

=back

=cut

sub clear_cache_cmd {
    my ( $app, @users ) = @_;

    if ( @users == 0 || $users[0] eq '--all' ) {
        shift @users;
        die "--all can not be mixed with usernames\n" if @users;
    }
    else {
        foreach my $user (@users) {
            _validate_user_arg( $app, $user );
        }
    }

    clear_cache(@users);

    return 1;
}

=head2 clear_cache()

Clear the cache for a given list of users if received.  Otherwise, clear the
cache for all users.

=over

=item users - optional

A list of cPanel users to clear the cache for

=back

=cut

sub clear_cache {
    my (@users) = @_;

    if (@users) {
        foreach my $user (@users) {
            _delete_glob( $var_cache_ea_nginx . "/*/$user/*" );
        }
    }
    else {
        _delete_glob( $var_cache_ea_nginx . "/*/*/*" );
    }

    return 1;
}

=head2 reload()

Reload nginx configuration

=cut

sub reload {
    _reload();
    clear_cache() if !-e $skip_clearing_cache_touch_file;
}

###############
#### helpers ##
###############

=head2 _wants_http2()

Returns whether http2 is enabled or not.

=head3 RETURNS

Returns 1 if http2 is enabled.  0 otherwise.

=cut

our $wants_http2;

sub _wants_http2 {
    if ( !defined $wants_http2 ) {
        $wants_http2 = -e "/etc/nginx/conf.d/http2.conf" ? 1 : 0;
    }

    return $wants_http2;
}

=head2 _has_ipv6()

Returns whether IPv6 is enabled or not.

=head3 RETURNS

Returns 1 if IPv6 is enabled.  0 otherwise.

=cut

our $has_ipv6;

sub _has_ipv6 {
    $has_ipv6 //= -f '/proc/net/if_inet6' ? 1 : 0;
    return $has_ipv6;
}

=head2 ensure_valid_nginx_config()

Attempt to verify that the nginx binary reports that it has a valid
configuration in place. Attempt to fix any errors that the nginx binary reports.
Dies if it is unable to verify a valid nginx configuration.  This effectively
prevents the script from reloading nginx with a bad configuration and gives a
warning to the user that they will need to manually resolve the issue before
reloading nginx.

=cut

sub ensure_valid_nginx_config {
    my ($depth) = @_;
    $depth //= 0;

    # limit recursion depth to 25
    my $max_depth = 25;

    my $nginx_bin = _get_nginx_bin();

    my $combined_output = '';
    my $output_handler  = IO::Callback->new( '>', sub { $combined_output .= shift } );
    my $run             = Cpanel::SafeRun::Object->new(
        program => $nginx_bin,
        args    => ['-t'],
        stdout  => $output_handler,
        stderr  => $output_handler,
    );

    my $resolved_issue;
    if ( $run->CHILD_ERROR() ) {
        $resolved_issue = _attempt_to_fix_syntax_errors($combined_output);
    }
    else {
        print "Verified valid syntax for nginx configuration\n";
        return;
    }

    ( $resolved_issue && $depth < $max_depth )
      ? return ensure_valid_nginx_config( ++$depth )
      : die "Unable to ensure a valid nginx configuration:\n\n$combined_output\n\nYou will need to manually resolve this issue.\n";

    return;
}

=head2 caching_defaults()

Returns a hash with the hardcoded caching fallbacks in case the user / global
defined files are either missing altogether or missing a setting.  The return
changes depending on whether micro caching is enabled or not.  Micro caching
can be enabled by putting the following touch file in place:

/etc/nginx/ea-nginx/enable.micro-cache-defaults

=head3 RETURNS

A hash of hardcoded caching fallbacks.

=cut

sub caching_defaults {    # do not want `our %caching_defaults` or a hash reference so that no one can accidentally change it

    # non-micro
    my %defaults = (
        proxy_cache_valid => {
            "200 301 302" => "60m",
            "404"         => "1m",
        },
    );

    if ( -e '/etc/nginx/ea-nginx/enable.micro-cache-defaults' ) {

        # micro
        %defaults = (
            proxy_cache_valid => {
                "301 302" => "5m",
                "404"     => "1m",
            },
        );
    }

    # hard coded fallback in case they remove stuff from /etc/nginx/ea-nginx/cache.json
    return (
        %defaults,
        enabled                       => 1,
        logging                       => 0,
        x_cache_header                => 0,
        zone_size                     => "10m",
        inactive_time                 => "60m",
        levels                        => "1:2",
        proxy_cache_use_stale         => "error timeout http_429 http_500 http_502 http_503 http_504",
        proxy_cache_background_update => "on",
        proxy_cache_revalidate        => "on",
        proxy_cache_min_uses          => 1,
        proxy_cache_lock              => "off",
    );
}

sub _bool_param {
    my ( $flag, $value ) = @_;

    die "--$flag value must be 1 or 0\n" if !defined $value || ( $value ne "1" && $value ne "0" );

    return $value;
}

sub _jsonify_caching_booleans {
    my ($hr) = @_;

    require JSON::PP;
    for my $boolkey (qw(enabled logging x_cache_header)) {
        next if !exists $hr->{$boolkey};
        $hr->{$boolkey} = $hr->{$boolkey} ? JSON::PP::true() : JSON::PP::false();
    }

    return;
}

=head2 _write_json()

Given a filename and a hashref of data to write to it, it will write the data
to the file in json pretty format.

=over

=item file

A valid filename

=item ref

A hashref containing data to write to the filename

=back

=cut

sub _write_json {
    my ( $file, $ref ) = @_;

    my $transaction = Cpanel::Transaction::File::JSON->new( path => $file, "permissions" => 0644 );
    $transaction->set_data($ref);

    $transaction->save_pretty_canonical_or_die();
    $transaction->close_or_die();

    return 1;
}

=head2 _render_tt_to_file()

Consume a given template toolkit file and produce a given output file with the
given data.

=over

=item tt_file

The path to the template toolkit file to consume located within
'/etc/nginx/ea-nginx/'

=item output_file

The path of the configuration file to output to within the '/etc/nginx/conf.d/'
directory.

=item tt_data

A hashref of data that will be used by the template toolkit file.

=back

=cut

my $tt;

sub _render_tt_to_file {
    my ( $tt_file, $output_file, $tt_data ) = @_;

    $tt ||= Template->new( { INCLUDE_PATH => "/etc/nginx/" } );

    my $output_path = "/etc/nginx/conf.d/$output_file";
    path($output_path)->touchpath;

    my $output_tt = path("/etc/nginx/ea-nginx/$tt_file")->slurp;

    $tt->process(
        \$output_tt, $tt_data,
        sub {
            my ($out) = @_;
            path($output_path)->spew($out);
            return 1;
        }
    );

    if ( $tt->error() ) {
        my $tt_err = $tt->error();
        unlink $output_path;
        die "$tt_err\nFailed to ensure “$output_path” does not exist: $!\n" if -e $output_path;
        die $tt_err;
    }

    return 1;
}

=head2 _do_other_global_config()

Allow for integrators to customize the configuration.  It consumes and executes
any executable scripts within the '/etc/nginx/ea-nginx/config-scripts/global'
directory.  It also gets called after all other global configurations meaning
that it can overwrite them if the integrator wishes for the scripts to do so.

=cut

sub _do_other_global_config {

    # /etc/nginx/ea-nginx/config-scripts/global/* allows for future
    #  _do_other_config_user($user) via /etc/nginx/ea-nginx/config-scripts/user/*

    for my $script ( File::Glob::bsd_glob("/etc/nginx/ea-nginx/config-scripts/global/*") ) {

        # Seen during the install of ea-modsec30-connector-nginx
        # During the install of multiple packages, the script
        #     - May not actually be completely there it ends in .dpkg-new
        #     - May not be executable
        if ( -x $script && $script !~ m/dpkg-new$/ ) {
            print "Running (global) “$script” …\n";
            system($script) && warn "“$script” did not exit clean\n";
        }
        else {
            print "Skipping “$script” as it is not executable\n";
        }
    }

    return;
}

=head2 _get_application_paths()

Populates a given hashref with the path to the binaries for the global default
versions of ruby, python, and nodejs.

=over

=item app

A hashref to populate with the paths to the binaries.  Note, the given hashref
will be altered by this sub.

=back

=cut

sub _get_application_paths {
    my ($app) = @_;
    return Cpanel::Config::userdata::PassengerApps->ensure_paths( $app, 0 );
}

=head2 _write_global_cpanel_localhost()

Write '/etc/nginx/conf.d/includes-optional/cpanel-proxy-xt.conf' with a unique
cPanel-localhost header.  The cPanel-localhost will be refreshed if the file
that it is stored in does not exist or has an mtime more than 30 minutes old.

=cut

sub _write_global_cpanel_localhost {

    # regen new value if its not set or if its been at least half an hour
    _write_cpanel_localhost_header() if !-e $proxy_header_file || ( stat(_) )[9] < ( time() - 1800 );

    my $conf = Cpanel::JSON::LoadFile($proxy_header_file);
    my $val  = $conf->{'cPanel-localhost'} || _write_cpanel_localhost_header();

    my $path     = Path::Tiny::path($cpanel_proxy_file);
    my $contents = <<"CPANEL_PROXY";
proxy_set_header cPanel-localhost $val;

# for secure use of proxying to Apache w/ mod_remoteip
proxy_headers_hash_bucket_size 128;
proxy_set_header X-Forwarded-For-$val \$proxy_add_x_forwarded_for;
CPANEL_PROXY
    $path->spew($contents);
    $path->chmod(0600);

    return;
}

=head2 _write_cpanel_localhost_header()

Generate a new cPanel-localhost header, write the cPanel-localhost header to
/etc/nginx/ea-nginx/cpanel_localhost_header.json, and rebuild and restart
apache so that it can pick up the new cPanel localhost header that nginx will
send.

=head3 RETURNS

Returns the new cPanel localhost header value.

=cut

sub _write_cpanel_localhost_header {
    my $rand = Cpanel::Rand::Get::getranddata(64);

    # Though technically legal, NGINX does not like _ in header names (for good reason honestly)
    # and we want to do things like: proxy_set_header X-Forwarded-For-<RAND> …;
    $rand =~ s/_/-/g;

    my %headers = (
        'cPanel-localhost' => $rand,
    );

    _write_json( $proxy_header_file, \%headers );
    chmod 0600, $proxy_header_file;    # in case it is reset out form under the package
    _rebuild_and_restart_apache();     # needed so that apache can pick up the new header file
    return $headers{"cPanel-localhost"};
}

=head2 _write_global_nginx_conf()

Update '/etc/nginx/nginx.conf' with the custom values for 'worker_processes'
and 'worker_shutdown_timeout' if any.  Otherwise, update it with a default
value for these two configuration variables.

=cut

sub _write_global_nginx_conf {
    my $main = path("/etc/nginx/nginx.conf");
    my $cont = $main->slurp;

    my $local_settings = _get_settings_hr();

    # worker_processes
    my $val = "1";
    if ( exists $local_settings->{worker_processes} ) {
        if ( $local_settings->{worker_processes} =~ m/^[1-9][0-9]*$/ || $local_settings->{worker_processes} eq "auto" ) {
            $val = $local_settings->{worker_processes};
        }
        else {
            warn "Custom `worker_processes` in “$settings_file” is not a number (and is not “auto”), falling back to “$val” …\n";
        }
    }
    $cont =~ s/^(\s*worker_processes\s+)\S+(\s*;)/$1$val$2/gm;

    # worker_shutdown_timeout
    $val = "10s";
    if ( exists $local_settings->{worker_shutdown_timeout} ) {
        if ( $local_settings->{worker_shutdown_timeout} =~ m/^[1-9][0-9]*(?:ms|[smhdwMy])?$/ ) {
            $val = $local_settings->{worker_shutdown_timeout};
        }
        else {
            warn "Custom `worker_shutdown_timeout` in “$settings_file” is not an NGINX time value, falling back to “$val” …\n";
        }
    }
    $cont =~ s/^(\s*worker_shutdown_timeout\s+)\S+(\s*;)/$1$val$2/gm;

    $main->spew($cont);

    return 1;
}

=head2 _write_global_cpanel_proxy_non_ssl()

Update '/etc/nginx/conf.d/cpanel-proxy-non-ssl.conf' with the correct values for
IPv6 and USER_ID.

=cut

sub _write_global_cpanel_proxy_non_ssl {
    my $ipv6   = _has_ipv6();
    my $prefix = $ipv6 ? "" : "# server does not have IPv6 enabled: ";

    my $cpns    = path("/etc/nginx/conf.d/cpanel-proxy-non-ssl.conf");
    my $content = $cpns->slurp;

    # toggle IPv6
    $content =~ s/^.*(listen\s+\[::\]:)/    $prefix$1/gm;

    my $user_id_prefix = _wants_user_id() ? '' : '# ';

    # toggle $USER_ID
    $content =~ s/^.*(set\s+\$USER_ID\s+""\s*;)/    $user_id_prefix$1/gm;

    $cpns->spew($content);

    return 1;
}

=head2 _write_global_default()

Update '/etc/nginx/conf.d/default.conf' with the correct values for IPv6, http2,
USER_ID, reuseport, and the correct default ssl certificate to use on the system.

=cut

sub _write_global_default {
    my $reuseport = Cpanel::Kernel::system_is_at_least('3.9.0') ? 1 : 0;
    my $ipv6      = _has_ipv6();
    my $http2     = _wants_http2();
    my $wants_uid = _wants_user_id();

    my $ssl_certificate =
      -f '/var/cpanel/ssl/cpanel/mycpanel.pem'
      ? '/var/cpanel/ssl/cpanel/mycpanel.pem'
      : '/var/cpanel/ssl/cpanel/cpanel.pem';

    return _render_tt_to_file(
        'default.conf.tt' => 'default.conf',
        {
            reuseport           => $reuseport,
            ipv6                => $ipv6,
            http2               => $http2,
            ssl_certificate     => $ssl_certificate,
            ssl_certificate_key => $ssl_certificate,
            uid                 => $wants_uid,
        },
    );
}

=head2 _write_global_passenger()

Write '/etc/nginx/conf.d/passenger.conf' with the correct application paths for
ruby, python, and nodejs

=cut

sub _write_global_passenger {
    unlink '/etc/nginx/conf.d/passenger.conf';    # will get re-written fresh below if its needed, otherwise we want it gone
    return 1 unless -e '/etc/nginx/modules/ngx_http_passenger_module.so';

    my $defaults_hr = { name => "global passenger defaults" };
    _get_application_paths($defaults_hr);

    my $passenger_root = $defaults_hr->{ruby};
    $passenger_root =~ s{/[^/]+$}{/../share/passenger/phusion_passenger/locations.ini};

    my $passenger_instance_registry_dir = $defaults_hr->{ruby};
    if ( $passenger_instance_registry_dir =~ m{^/opt/cpanel} ) {
        $passenger_instance_registry_dir =~ s{/[^/]+$}{/../../var/run/passenger-instreg};
    }
    else {
        $passenger_instance_registry_dir = '/opt/cpanel/ea-passenger/run/passenger-instreg';
    }
    File::Path::Tiny::mk($passenger_instance_registry_dir);

    return _render_tt_to_file(
        'ngx_http_passenger_module.conf.tt' => 'passenger.conf',
        {
            passenger => {
                global => {
                    passenger_root                  => $passenger_root,
                    passenger_instance_registry_dir => $passenger_instance_registry_dir,
                    default                         => $defaults_hr,
                },
            },
        },
    );
}

=head2 _get_settings_hr()

Returns a hashref containing nginx settings based on the default settings, the
contents of '/etc/nginx/ea-nginx/settings.json', and the calculated values of
'server_names_hash_bucket_size', and 'server_names_hash_max_size'.  The result
of this sub is cached and the cached result will be returned after the first
call.

=head3 RETURNS

A hashref similar to the following:

{
    apache_port_ip                => '127.0.0.1',
    server_names_hash_bucket_size => 128,
    server_names_hash_max_size    => 1024,
    apache_ssl_port_ip            => '127.0.0.1',
    client_max_body_size          => '128m',
    apache_port                   => 81,
    apache_ssl_port               => 444,
}

=cut

our $settings_hr;

sub _get_settings_hr {
    if ( !defined $settings_hr ) {
        my $global_settings = Cpanel::JSON::LoadFile($settings_file);
        $settings_hr = {
            _settings_defaults(),
            %{$global_settings},
        };

        # Do this here so that these two settings are always calculated
        # NOTE: The calculations are based on the advice given here:  http://nginx.org/en/docs/http/server_names.html#optimization
        $settings_hr->{server_names_hash_max_size}    = _get_server_names_hash_max_size();
        $settings_hr->{server_names_hash_bucket_size} = _get_server_names_hash_bucket_size( $settings_hr->{server_names_hash_max_size} );
    }

    return $settings_hr;
}

=head2 _get_server_names_hash_bucket_size()

Calculate the value of 'server_names_hash_bucket_size' based on the length of
the longest domain hosted on the server.

=head3 RETURNS

An integer greater than or equal to 128.  The integer must be a power of 2.

=cut

sub _get_server_names_hash_bucket_size {
    my ($server_names_hash_max_size) = @_;

    my $server_names_hash_bucket_size = $default_server_names_hash_bucket_size;

    my $domain_length_info = _get_domain_length_info();

    # server_names_hash_bucket_size should be power of 2 for CPU cache line size
    # optimization purposes.  Set it to first power of 2 larger then the longest
    # server_name or 128 if it is sufficient
    # As a sanity check, we also need to ensure that
    # server_names_hash_max_size * server_names_hash_bucket_size
    # is larger than the total bytes taken up by all the domains combined
    my $server_names_total_size = $server_names_hash_max_size * $server_names_hash_bucket_size;
    while ( $server_names_hash_bucket_size < $domain_length_info->{longest} || $server_names_total_size < $domain_length_info->{total_length} ) {
        $server_names_hash_bucket_size *= 2;
        $server_names_total_size = $server_names_hash_max_size * $server_names_hash_bucket_size;
    }

    return $server_names_hash_bucket_size;
}

=head2 _get_aligned_domain_length()

Given an integer, it returns the smallest integer that is larger than the
integer and divisible by 8.

=over

=item domain_length

An unsigned integer to align to 8

=back

=head3 RETURNS

The smallest integer that is larger than the given integer and divisible by 8

=cut

sub _get_aligned_domain_length {
    my ($domain_length) = @_;
    my $align_to = 8;           # Assumes x86_64

    my $remainder = $domain_length % $align_to;

    # if there is a remainder, then we need to align the domain length
    # otherwise, it is already divisible by 8 and there is nothing to do
    my $aligned_domain_length = $remainder ? $domain_length + $align_to - $remainder : $domain_length;

    return $aligned_domain_length;
}

=head2 _get_domain_length_info()

Returns a hashref containing two keys that represent that have values
representing the length of the longest domain on the server and the total length
of all domains hosted on the server

=head3 RETURNS

A hashref similar to the following:

{
    longest      => 200,
    total_length => 4242,
}

=cut

sub _get_domain_length_info {

    # If a miss is encountered while building the server_names_hash, then up to
    # 5/8 of the hash length could be unused
    my $nginx_buffer = $default_server_names_hash_bucket_size / 8 * 5;

    my $service_subdomain_buffer = 16;    # Add padding for service subdomains (autodiscover. is currently the longest)

    my $longest      = 0;
    my $total_length = 0;

    my $user_lookup = _get_user_domains();
    for my $usr ( sort keys %$user_lookup ) {
        for my $domain ( @{ $user_lookup->{$usr} } ) {
            my $domain_length         = length $domain;
            my $aligned_domain_length = _get_aligned_domain_length($domain_length);
            my $total_domain_length   = $aligned_domain_length + $nginx_buffer + $service_subdomain_buffer;
            $longest = $total_domain_length if $total_domain_length > $longest;

            # There are up to 10 copies of the domain generated between the two
            # server_name blocks for the domain.  On average, this number is 8,
            # but we go with the larger number here to be sure that we the
            # configure nginx with large enough values to generate its
            # server_names hash
            $total_domain_length *= 10;
            $total_length        += $total_domain_length;
        }
    }

    my $domain_length_info = {
        longest      => $longest,
        total_length => $total_length,
    };

    return $domain_length_info;
}

=head2 _get_server_names_hash_max_size()

Calculates the value of 'server_names_hash_max_size' based on the total number
of domains hosted on the server.

=head3 RETURNS

An integer greater than or equal to 1024.

=cut

sub _get_server_names_hash_max_size {
    my $minimum_hash_max_size = 1024;
    my $num_domains           = _get_num_total_domains();

    # We generate ~8 server blocks for each domain
    my $num_server_blocks = $num_domains * 8;

    # At a minimum, 'server_names_hash_max_size' needs to match the number of server blocks generated
    # If the number of server blocks to generate is larger than the minimum that we allow this setting to be
    # then increase the setting size to the number of server blocks to generate
    # otherwise, honor the minimum size
    return ( $minimum_hash_max_size < $num_server_blocks ) ? $num_server_blocks : $minimum_hash_max_size;
}

=head2 _write_global_ea_nginx()

Uses template toolkit file to write '/etc/nginx/conf.d/ea-nginx.conf'

=cut

sub _write_global_ea_nginx {
    my $cur_settings = _get_settings_hr();
    my $ea4_conf_hr  = Cpanel::EA4::Conf->instance->as_hr();

    my $vhosts = _get_httpd_vhosts_hash();
    my %ips    = map { $vhosts->{$_}{ip} => 1 } keys %{$vhosts};

    return _render_tt_to_file(
        'ea-nginx.conf.tt' => 'ea-nginx.conf',
        { ea4conf => $ea4_conf_hr, settings => $cur_settings, ips => [ sort keys %ips ] }
    );
}

=head2 _settings_defaults()

Returns a hash containing the default settings for nginx.  This is needed as a
fallback in case '/etc/nginx/ea-nginx/settings.json' is missing a crucial
value needed for rendering the template toolkit for 'ea-nginx.conf'.

=cut

sub _settings_defaults {

    my $cpconf_hr = _get_cpconf_hr();

    my %ports;
    foreach my $key ( 'apache_port', 'apache_ssl_port' ) {
        $ports{$key} = $cpconf_hr->{$key} =~ /:([0-9]+)$/ ? $1 : 0;
        if ( $ports{$key} < 1 || $ports{$key} > 65535 ) {    # last ditch fall back if cpconf has an invalid value
            $ports{$key} = $key eq 'apache_port' ? 81 : 444;
        }
    }

    # hard coded fallback in case they remove stuff from /etc/nginx/ea-nginx/settings.json
    return (
        apache_port          => $ports{apache_port},
        apache_port_ip       => '127.0.0.1',
        apache_ssl_port      => $ports{apache_ssl_port},
        apache_ssl_port_ip   => '127.0.0.1',
        client_max_body_size => '128m',
    );
}

=head2 _write_global_logging()

Uses template toolkit file to write '/etc/nginx/conf.d/global-logging.conf' and
enables/disables piped logging based on the global system setting

=cut

sub _write_global_logging {

    my $logging_hr = _get_logging_hr();
    _render_tt_to_file(
        'global-logging.tt' => 'global-logging.conf',
        {
            logging  => $logging_hr,
            hostname => scalar( Cpanel::Hostname::gethostname() ),
        },
    );

    if ( $logging_hr->{piped_logs} ) {
        path($piped_module_conf)->spew("load_module modules/ngx_http_pipelog_module.so;");
    }
    else {
        unlink $piped_module_conf;
        die "Failed to ensure “$piped_module_conf” does not exist: $!\n" if -e $piped_module_conf;
    }

    return 1;
}

=head2 _reload()

Reload nginx configuration

=over

=item new_file - optional

If nginx fails to reload, then this file will be removed and second attempt to
reload nginx without this file in place will take place

=back

=cut

sub _reload {
    my ($new_file) = @_;

    ensure_valid_nginx_config();    # dies if it fails to ensure a valid config

    if ( system("/usr/local/cpanel/scripts/restartsrv_nginx reload") != 0 ) {
        if ($new_file) {
            warn "Could not reload generated nginx config, removing and attempting reload without it: $?\n";
            unlink $new_file;
            system("/usr/local/cpanel/scripts/restartsrv_nginx reload") || return 1;
        }
        exit 1;
    }

    return 1;
}

=head2 _validate_domains_data_or_die( $user, $domains_data )

Validates that the given user was able to load its domain userdata and that the
domain userdata has the minimum expected data

=over

=item user

The username that is being validated

=item domains_data

A hashref containing the domain data that is being validated

=back

=cut

sub _validate_domains_data_or_die {
    my ( $user, $domains_data ) = @_;

    die "Failed to load user ‘$user’s web vhost data\n" unless $domains_data;
    die "No vhosts config data for user ‘$user’\n"      unless $domains_data->{main_domain};
    return;
}

=head2 _validate_user_uid_and_gid($user)

Validates that the given user has a valid system UID and GID

=over

=item user

The username being validated

=back

=cut

sub _validate_user_uid_and_gid {
    my ($user) = @_;

    die "Unable to determine user id for $user, assuming invalid user and skipping\n"  unless ( Cpanel::PwCache::getpwnam($user) )[2];
    die "Unable to determine group id for $user, assuming invalid user and skipping\n" unless _get_group_for($user);
    return;
}

=head2 _write_user_conf()

Writes the configuration for the given user.  This includes the caching
configuration, server block for the primary and parked domains, and the server
blocks for any subdomains and/or addon domains belonging to the user.

=over

=item user

A valid cPanel username

=item global_config_data

A hashref containing data that should be treated as read only that all users
can use to build their configs with.

=back

=cut

sub _write_user_conf {
    my ( $user, $global_config_data ) = @_;

    _validate_user_uid_and_gid($user);

    my $domains_data = Cpanel::Config::userdata::Load::load_userdata_main($user);

    _validate_domains_data_or_die( $user, $domains_data );

    my $userdata_for_main_domain = _get_userdata_for( $user => $domains_data->{main_domain} );

    my %addon_subdomains = map { $domains_data->{addon_domains}{$_} => 1 }
      grep { $domains_data->{addon_domains}{$_} }
      keys %{ $domains_data->{addon_domains} };

    my @actual_subdomains = grep { $_ && !exists $domains_data->{addon_domains}{$_} && !$addon_subdomains{$_} } @{ $domains_data->{sub_domains} };

    my %subdomains_hash = map { $_ => 1 } @{ $domains_data->{sub_domains} };

    my $userconf = "/etc/nginx/conf.d/users/$user.conf";

    print "Setting up $userconf …\n";

    if ( !defined &scripts::ea_nginx_userdata::run ) {
        my $prefix = "/usr/local/cpanel/scripts/ea-nginx";
        if ( $0 =~ m{/SOURCES/} ) {

            # Can’t use FindBin because the script name is different in repo and in RPM
            require Cwd;
            $prefix = Cwd::abs_path($0);
        }

        require "$prefix-userdata";
    }
    scripts::ea_nginx_userdata::run($user);

    my $caching_hr = _get_caching_hr($user);
    my $pre_server = "";
    if ( $caching_hr->{enabled} ) {
        $pre_server = "proxy_cache_path /var/cache/ea-nginx/proxy/$user levels=$caching_hr->{levels} keys_zone=$user:$caching_hr->{zone_size} inactive=$caching_hr->{inactive_time};\n\n";
    }

    my $domains = [
        $domains_data->{main_domain},
        'www.' . $domains_data->{main_domain},
    ];

    push( @$domains, "mail.$domains_data->{main_domain}" ) unless $subdomains_hash{"mail.$domains_data->{main_domain}"};
    push( @$domains, "ipv6.$domains_data->{main_domain}" ) if exists $userdata_for_main_domain->{ipv6};

    foreach my $dom ( @{ $domains_data->{parked_domains} } ) {
        push @$domains, $dom;
        if ( !Cpanel::WildcardDomain::is_wildcard_domain($dom) ) {
            push @$domains, "www.$dom";
            push( @$domains, "mail.$dom" ) unless $subdomains_hash{"mail.$dom"};
        }
    }

    path($userconf)->spew( $pre_server . "#### main domain for $user ##\n" );
    _render_and_append(
        {
            user               => $user,
            domains            => $domains,
            global_config_data => $global_config_data,
        }
    );

    _init_logs_for( $user, $domains_data->{main_domain} );

    if (@actual_subdomains) {
        path($userconf)->append("\n#### sub domains for $user ##\n");
        for my $subdom (@actual_subdomains) {
            my $subdoms = [$subdom];
            push( @$subdoms, "www.$subdom" ) unless Cpanel::WildcardDomain::is_wildcard_domain($subdom);
            _render_and_append(
                {
                    user               => $user,
                    domains            => $subdoms,
                    global_config_data => $global_config_data,
                }
            );

            _init_logs_for( $user, $subdom );
        }
    }

    if ( keys %{ $domains_data->{addon_domains} } ) {
        path($userconf)->append("\n#### addon domains for $user ##\n");
        for my $aod ( sort keys %{ $domains_data->{addon_domains} } ) {
            my $sub = $domains_data->{addon_domains}{$aod};

            next unless $sub;

            if ( !$subdomains_hash{$sub} ) {
                warn "Skipping addon domain $aod for $user, corresponding sub domain $sub does not exist";
                next;
            }

            my $ao_domains = [];

            # subdomain first -- does not add mail subdomain
            push @$ao_domains, $sub;
            push( @$ao_domains, "www.$sub" ) unless Cpanel::WildcardDomain::is_wildcard_domain($sub);

            # addon domain second -- adds mail subdomain
            push @$ao_domains, $aod;
            if ( !Cpanel::WildcardDomain::is_wildcard_domain($aod) ) {
                push @$ao_domains, "www.$aod";
                push( @$ao_domains, "mail.$aod" ) unless $subdomains_hash{"mail.$aod"};
            }

            _render_and_append(
                {
                    user               => $user,
                    domains            => $ao_domains,
                    global_config_data => $global_config_data,
                }
            );

            _init_logs_for( $user, $sub );
        }
    }

    return 1;
}

our ( $server_tt, $docroots, $logging_hr, $caching_cache, $global_caching );

=head2 caching_global()

Return a hashref containing nginx's global caching settings.  This sub will
return a cached result after the first call.

=head3 RETURNS

A hashref similar to the following:

{
    'inactive_time' => '60m',
    'proxy_cache_use_stale' => 'error timeout http_429 http_500 http_502 http_503 http_504',
    'proxy_cache_valid' => {
        '200 301 302' => '60m',
        '404' => '1m'
    },
    'zone_size' => '10m',
    'proxy_cache_lock' => 'off',
    'proxy_cache_revalidate' => 'on',
    'proxy_cache_background_update' => 'on',
    'levels' => '1:2',
    'enabled' => bless( do{\(my $o = 1)}, 'JSON::PP::Boolean' ),
    'proxy_cache_min_uses' => 1,
    'logging' => bless( do{\(my $o = 0)}, 'JSON::PP::Boolean' ),
};

=cut

sub caching_global {
    return $global_caching if $global_caching;

    my $caching_global = Cpanel::JSON::LoadFile($cache_file);

    # UGMO :/ clean it up if the touch file exists and they have the defaults
    # if they happen to edit it and make it the default and they really want that then they should not have this touchfile
    if ( -e '/etc/nginx/ea-nginx/enable.micro-cache-defaults' ) {

        # only change it if its the non-micro default
        if (   keys %{ $caching_global->{proxy_cache_valid} } == 2
            && exists $caching_global->{proxy_cache_valid}{"200 301 302"}
            && $caching_global->{proxy_cache_valid}{"200 301 302"} eq "60m"
            && exists $caching_global->{proxy_cache_valid}{404}
            && $caching_global->{proxy_cache_valid}{404} eq "1m" ) {

            warn "Changing default `proxy_cache_valid` to micro-cache version per the existance of the `/etc/nginx/ea-nginx/enable.micro-cache-defaults` touch-file\n";
            delete $caching_global->{proxy_cache_valid}{"200 301 302"};
            $caching_global->{proxy_cache_valid}{"301 302"} = "5m";
            _write_json( $cache_file, $caching_global );
        }
    }
    else {
        # only change it back if its the micro default
        if (   keys %{ $caching_global->{proxy_cache_valid} } == 2
            && exists $caching_global->{proxy_cache_valid}{"301 302"}
            && $caching_global->{proxy_cache_valid}{"301 302"} eq "5m"
            && exists $caching_global->{proxy_cache_valid}{404}
            && $caching_global->{proxy_cache_valid}{404} eq "1m" ) {

            warn "Changing default `proxy_cache_valid` to non-micro-cache version per the absence of the `/etc/nginx/ea-nginx/enable.micro-cache-defaults` touch-file\n";
            $caching_global->{proxy_cache_valid}{"200 301 302"} = "60m";
            delete $caching_global->{proxy_cache_valid}{"301 302"};
            _write_json( $cache_file, $caching_global );
        }
    }

    return $caching_global;
}

=head2 _get_caching_hr()

Returns the given users cache settings.  The cache settings have the following
order of precendence:

1.  user specific based on '/var/cpanel/userdata/$user/nginx-cache.json'
2.  global cache settings based on '/etc/nginx/ea-nginx/cache.json'
3.  The caching defaults provided by caching_defaults()

NOTE: The results of this sub are cached for each user in the $caching_cache
hashref and the cached result is returned on subsequent calls to this sub.

=over

=item user

A valid cPanel username

=back

=head3 RETURNS

A hashref representing the user's caching configuration.  See caching_global()
for a detailed example of what this data structure looks like.

=cut

sub _get_caching_hr {
    my ($user) = @_;

    if ( !exists $caching_cache->{$user} ) {

        # at this point that file should exist; if it does not we want this to barf so we know about it
        my $global_caching = caching_global();

        my $user_caching = eval { Cpanel::JSON::LoadFile("/var/cpanel/userdata/$user/nginx-cache.json") } || {};
        $caching_cache->{$user} = {
            caching_defaults(),
            %{$global_caching},
            %{$user_caching},
        };
    }

    return $caching_cache->{$user};
}

=head2 _get_logging_hr()

Return a hashref containing the user's global logging preferences.  This sub
will return a cached result after the first call.

=head3 RETURNS

This returns a hashref similar to the following:

{
    'default_format_name' => 'combined',
    'enable_cache_log' => bless( do{\(my $o = 0)}, 'JSON::PP::Boolean' ),
    'loglevel' => 'warn',
    'piped_logs' => '1'
};

=cut

sub _get_logging_hr {
    return $logging_hr if $logging_hr;

    $logging_hr->{piped_logs} = Whostmgr::TweakSettings::get_value( Main => 'enable_piped_logs' );

    my $wwwacct = Cpanel::Config::LoadWwwAcctConf::loadwwwacctconf() // {};
    $logging_hr->{default_format_name} = $wwwacct->{LOGSTYLE} || "combined";

    my $global_caching = caching_global();
    $logging_hr->{enable_cache_log} = $global_caching->{logging};

    if ( $logging_hr->{default_format_name} ne "combined" && $logging_hr->{default_format_name} ne "common" ) {
        warn "Invalid “LOGSTYLE”, using `combined`\n";
        $logging_hr->{default_format_name} = "combined";
    }

    my $e4c = Cpanel::EA4::Conf::Tiny::get_ea4_conf_hr();
    $logging_hr->{loglevel} = $e4c->{loglevel} || 'warn';

    return $logging_hr;
}

=head2 _get_group_for()

Returns the group name for the given username.  The result is cached in a hash
and the cached result will be returned if the given username exists in the hash.

=over

=item user

A valid cPanel username

=back

=head3 RETURNS

A string representing the group name of the given username or undef.

=cut

my %group_cache;

sub _get_group_for {
    my ($user) = @_;
    return $group_cache{$user} if $group_cache{$user};

    # prefer: group same name as user
    $group_cache{$user} = scalar getgrnam($user) ? $user : undef;
    return $group_cache{$user} if $group_cache{$user};

    # otherwise: via gid
    my $gid = _get_gid_for($user);
    return unless $gid;

    $group_cache{$user} = getgrgid($gid);

    return $group_cache{$user};
}

=head2 _get_gid_for()

Returns the gid for the given username.  The result is cached in a hash
and the cached result will be returned if the given username exists in the hash.

=over

=item user

A valid cPanel username

=back

=head3 RETURNS

An integer representing the group ID of the given username or undef.

=cut

my %gid_cache;

sub _get_gid_for {
    my ($user) = @_;
    return $gid_cache{$user} if $gid_cache{$user};

    my $gid = ( getpwnam($user) )[3];
    return unless $gid;

    $gid_cache{$user} = $gid;
    return $gid_cache{$user};
}

=head2 _get_domains_with_ssls()

Returns a hashref where the keys are the domains that have SSL enabled and the
value is 1 for every key.

=cut

our $domains_with_ssl;

sub _get_domains_with_ssls {
    return $domains_with_ssl if $domains_with_ssl;

    my %ssl_vhosts = map {
        $_ =~ s/_SSL$//;
        $_ => 1
    } grep { substr( $_, -4 ) eq '_SSL' ? 1 : 0 } keys %{ _get_httpd_vhosts_hash() };

    $domains_with_ssl = \%ssl_vhosts;

    return $domains_with_ssl;
}

=head2 _get_httpd_vhosts_hash()

Returns a hashref containing the apache configuration structure for all the
vhosts that will be configured on the system.  See
<Cpanel::ConfigFiles::Apache::Config> for more details regarding this data
structure.  The result of this sub is cached and the cached result will be
returned after the first call.

=cut

our $httpd_vhosts_hr;

sub _get_httpd_vhosts_hash {
    return unless ( eval { require Cpanel::ConfigFiles::Apache::Config; 1; } );

    $httpd_vhosts_hr ||= Cpanel::ConfigFiles::Apache::Config::get_httpd_vhosts_hash();

    return $httpd_vhosts_hr;
}

=head2 _is_standalone()

Returns whether nginx is currently in standalone mode or not.  The return value
is cached and the cached value will be returned on subsequent calls to this sub.

=head3 RETURNS

Returns 1 or 0

=cut

our $standalone;

sub _is_standalone {
    $standalone //= -e "/etc/nginx/ea-nginx/enable.standalone" ? 1 : 0;
    return $standalone;
}

=head2 _render_and_append()

Appends the server block for the given domains to the given user's
configuration file.  Dies if it fails to render the server block for the user.

=over

=item args

A hashref containing named args to be passed in.

=over 2

=item user

A valid cPanel username

=item domains

A arrayref of domains names

=item global_config_data

A hashref containing data that should be treated as read only that all users
can use to build their configs with.

=back

=back

=cut

sub _render_and_append {
    my ($args) = @_;

    my $user               = $args->{user};
    my $domains            = $args->{domains};
    my $global_config_data = $args->{global_config_data};

    my $group = _get_group_for($user);

    # at this point that file should exist; if it does not we want this to barf so we know about it
    my $cpanel_password_protected_directories = Cpanel::JSON::LoadFile("/var/cpanel/userdata/$user/cpanel_password_protected_directories.json");
    my $cpanel_redirects                      = Cpanel::JSON::LoadFile("/var/cpanel/userdata/$user/cpanel_redirects.json");

    my $userconf = "/etc/nginx/conf.d/users/$user.conf";
    $tt        ||= Template->new( { INCLUDE_PATH => "/etc/nginx/" } );
    $server_tt ||= path("/etc/nginx/ea-nginx/server.conf.tt")->slurp;

    my $user_docroots = _get_docroots_for($user);
    my $docroot       = $user_docroots->{ $domains->[0] };

    my $basic_auth       = _get_basic_auth( $user, $docroot, $cpanel_password_protected_directories );
    my $redirects        = _get_redirects( $domains, $cpanel_redirects );
    my $ssl_redirect     = _get_ssl_redirect( $user => $domains );
    my $secruleengineoff = -e "/etc/nginx/conf.d/modules/ngx_http_modsecurity_module.conf" ? _get_secruleengineoff( $user => $domains ) : 0;
    my $passenger_apps   = _get_passenger_apps( $user => $domains );
    my $caching_hr       = _get_caching_hr($user);

    # Include numeric UID if they want it
    my $uid = ( Cpanel::PwCache::getpwnam($user) )[2] if _wants_user_id();

    my $fpm_socket;
    my $fpm_socket_php_major_version;

    # Check if the LSAPI flag file exists
    my $lsapi_flag_file = "/etc/nginx/conf.d/modules/ea-nginx-lsapi-module.conf";
    my $lsapi_available = -e $lsapi_flag_file;
    my $lsapi_handler;
    foreach my $dom (@$domains) {
        my $has_fpm = Cpanel::PHPFPM::Get::get_php_fpm( $user, $dom );
        if ($has_fpm) {
            my $php_config_ref = Cpanel::PHP::Config::get_php_config_for_domains( [$dom] )->{$dom};

            if ($php_config_ref) {
                my ( $proxy, $socket ) = Cpanel::PHPFPM::Get::get_proxy_from_php_config_for_domain($php_config_ref);
                $fpm_socket = $socket;
                if ( $fpm_socket =~ m{/\w+-php(\d)\d/} ) {
                    $fpm_socket_php_major_version = $1;
                }
            }
            elsif ( $global_config_data->{standalone} ) {
                warn "Could not find PHP configuration for “$dom”, it will not be configured to use PHP-FPM\n";
            }

            last;
        } elsif ($lsapi_available) {
            my $php_config_ref;
            eval {
                $php_config_ref = Cpanel::PHP::Config::get_php_config_for_domains( [$dom] )->{$dom};
            };
            # Skip to next domain if there was an error or if the phpversion is not set
            next unless $php_config_ref && $php_config_ref->{'phpversion'};
            my $php = Cpanel::ProgLang->new(type => 'php');
            my $conf = Cpanel::ProgLang::Conf->new(type => $php->type());
            my $php_handler = $conf->get_package_info(package => $php_config_ref->{'phpversion'});

            # go next if domain's handler is not lsapi
            next unless $php_handler eq 'lsapi';

            # Construct the lsapi_handler variable
            $lsapi_handler = "application/x-httpd-" . $php_config_ref->{'phpversion'} . "___lsphp";
            last;
        }
    }

    $domains_with_ssl ||= _get_domains_with_ssls();

    my @service_subdomains;
    $httpd_vhosts_hr ||= _get_httpd_vhosts_hash();
    if ( exists $httpd_vhosts_hr->{"$domains->[0]_SSL"}{proxy_subdomains} ) {
        for my $sub ( sort keys %{ $httpd_vhosts_hr->{"$domains->[0]_SSL"}{proxy_subdomains} } ) {
            push @service_subdomains, map { "$sub.$_" } @{ $httpd_vhosts_hr->{"$domains->[0]_SSL"}{proxy_subdomains}{$sub} };
        }
    }

    my $combined_tls = Cpanel::Apache::TLS->get_tls_path( $domains->[0] );
    if ( !_is_certfile_valid($combined_tls) ) {
        if ( -f "/var/cpanel/ssl/cpanel/mycpanel.pem" ) {
            $combined_tls = "/var/cpanel/ssl/cpanel/mycpanel.pem";
        }
        else {
            $combined_tls = "/var/cpanel/ssl/cpanel/cpanel.pem";
        }
    }

    my $ip            = Cpanel::DomainIp::getdomainip( $domains->[0] ) || '127.0.0.1';                                                                        # juuust in case so we don’t break nginx w/ syntax error
    my $servername_ip = $ip ne '127.0.0.1' && $global_config_data->{domain_ips}{$ip} && $global_config_data->{domain_ips}{$ip} eq $domains->[0] ? $ip : "";

    # Use the public IP on NAT systems
    # NOTE: this is fine to run on non-NAT systems as it just returns the same IP
    $servername_ip = Cpanel::NAT::get_public_ip($servername_ip) if $servername_ip;

    my $wordpress_hr  = _get_wordpress_info( $user, $user_docroots->{ $domains->[0] } );
    my $has_wordpress = ( $wordpress_hr->{docroot_install} || scalar @{ $wordpress_hr->{non_docroot_uris} } ) ? 1 : 0;

    $tt->process(
        \$server_tt,
        {
            docroot             => $docroot,
            ssl_certificate     => $combined_tls,
            ssl_certificate_key => $combined_tls,
            ssl_redirect        => $ssl_redirect,
            secruleengineoff    => $secruleengineoff,
            domains             => $domains,
            service_subdomains  => \@service_subdomains,
            user                => $user,
            group               => $group,
            ( defined $uid ? ( uid => $uid ) : () ),
            proxy_ip                     => $ip,
            ip                           => $servername_ip,
            ipv6                         => $global_config_data->{ipv6},
            http2                        => $global_config_data->{http2},
            fpm_socket                   => $fpm_socket,
            fpm_socket_php_major_version => $fpm_socket_php_major_version,
            lsapi_handler                => $lsapi_handler,
            has_wordpress                => $has_wordpress,
            wordpress                    => {
                docroot_install  => $wordpress_hr->{docroot_install},
                non_docroot_uris => $wordpress_hr->{non_docroot_uris},
            },
            basic_auth => $basic_auth,
            redirects  => $redirects,
            logging    => $global_config_data->{logging},
            ea4conf    => $global_config_data->{e4c},
            settings   => $global_config_data->{cur_settings},
            passenger  => {
                apps => $passenger_apps,
            },
            behavior => {
                standalone => $global_config_data->{standalone},
                caching    => $caching_hr,
            },
            proxysubdomains_enabled => $global_config_data->{cpconf}->{proxysubdomains},
        },
        sub {
            my ($out) = @_;
            path($userconf)->append($out);
            return 1;
        }
    );

    if ( $tt->error() ) {
        unlink $userconf;
        die $tt->error();
    }

    return 1;
}

=head2 _init_logs_for()

Initializes the logs for a given user and domain.

=over

=item user

A valid cPanel username

=item domain

The domain to initialize

=back

=cut

sub _init_logs_for {
    my ( $user, $domain ) = @_;

    my $gid = _get_gid_for($user);

    # STD log
    Cpanel::FileUtils::TouchFile::touchfile("/var/log/nginx/domains/$domain");
    chmod 0640, "/var/log/nginx/domains/$domain";
    chown 0, $gid, "/var/log/nginx/domains/$domain";

    # SSL log
    Cpanel::FileUtils::TouchFile::touchfile("/var/log/nginx/domains/$domain-ssl_log");
    chmod 0640, "/var/log/nginx/domains/$domain-ssl_log";
    chown 0, $gid, "/var/log/nginx/domains/$domain-ssl_log";

    # Bytes log
    Cpanel::FileUtils::TouchFile::touchfile("/var/log/nginx/domains/$domain-bytes_log");
    chmod 0644, "/var/log/nginx/domains/$domain-bytes_log";
    chown 0, 0, "/var/log/nginx/domains/$domain-bytes_log";

    return;
}

=head2 _is_certfile_valid()

Tests if an SSL certificate file is valid

=head3 RETURNS

Returns false if the certificate file has been found to be invalid
Returns true otherwise

=cut

sub _is_certfile_valid {
    my ($certfile) = @_;

    # It cannot be valid if it is absent or empty
    if ( !-f $certfile || -z _ ) {
        print "Certificate file $certfile is absent or empty; excluding from config file\n";
        return;
    }

    # We cannot meaningfully test further if the openssl binary hasn't been found
    # This issue will have already been reported
    my $openssl = _get_openssl_obj();
    return 1 unless $openssl;

    my $output = $openssl->get_cert_text( { crtfile => $certfile } );
    if ( $output->{CHILD_ERROR} ) {
        print "Certificate file $certfile is invalid; excluding from config file: " . $output->{stderr} . "\n";
        return;
    }

    return 1;
}

=head2 _get_openssl_obj()

Gets a cached instance of a Cpanel::OpenSSL object
If not present in the cache, then allocates & caches

=head3 RETURNS

Returns a Cpanel::OpenSSL instance if OpenSSL binaries are present
Returns false otherwise

=cut

sub _get_openssl_obj {

    $openssl_obj //= Cpanel::OpenSSL->new();
    if ( !$openssl_obj ) {
        warn "The openssl binary could not be located\n";
    }

    return $openssl_obj;
}

=head2 _get_homedir()

Returns the path to the given cPanel user's homedir

=head3 RETURNS

The path to the user's homedir

=cut

sub _get_homedir {
    my ($user) = @_;
    return ( getpwnam($user) )[7];
}

=head2 _get_passenger_apps()

Given a user and an array reference of domains, it consumes the applications
userdatafile for the user, and returns an array reference of hashes for any
applications that are enabled for one of the given domains.

=over

=item user

A cPanel username

=item domains

A arrayreferences of domains owned by the user

=back

=head3 RETURNS

An array reference of hashes containing application data for any enabled
passenger applications

=cut

sub _get_passenger_apps {
    my ( $user, $domains ) = @_;
    my @apps;

    my %domains;
    @domains{ @{$domains} } = ();

    my $apps_hr = eval { Cpanel::JSON::LoadFile("/var/cpanel/userdata/$user/applications.json") } || {};

    for my $app_name ( sort keys %{$apps_hr} ) {
        my $app = $apps_hr->{$app_name};
        if ( $app->{enabled} && exists $domains{ $app->{domain} } ) {
            _get_application_paths($app);
            push @apps, $app;
        }
    }

    return \@apps;
}

=head2 _get_userdata_for()

Given a cPanel username and a domain that it owns, it returns the userdata for
the domain.

=over

=item user

A valid cPanel username

=item dom

A domain owned by the cPanel user

=back

=head3 RETURNS

A hashref representing the userdata for the user's domain

=cut

our %load_userdata_cache;

sub _get_userdata_for {
    my ( $user, $dom ) = @_;

    if ( !exists $load_userdata_cache{$user}{$dom} ) {
        $load_userdata_cache{$user}{$dom} = Cpanel::Config::userdata::Load::load_userdata( $user => $dom ) || {};
    }

    return $load_userdata_cache{$user}{$dom};
}

=head2 _get_ssl_redirect()

Given a cPanel username and an arrayref of domains that the cpanel user owns,
return 1 if any of the domains has force ssl redirect enabled.  Otherwise,
return undef

=over

=item user

A valid cPanel username

=item domains

An arrayref of domain names

=back

=head3 RETURNS

1 or undef

=cut

sub _get_ssl_redirect {
    my ( $user, $domains ) = @_;

    for my $dom ( @{$domains} ) {
        my $lu = _get_userdata_for( $user => $dom );
        return 1 if $lu->{ssl_redirect};
    }

    return;
}

=head2 _get_secruleengineoff()

Returns 1 if mod_security is enabled for the given user. Otherwise, returns
undef.

=over

=item user

A valid cPanel username

=item domains

An arrayref of domains

=back

=head3 RETURNS

1 or undef

=cut

sub _get_secruleengineoff {
    my ( $user, $domains ) = @_;

    for my $dom ( @{$domains} ) {
        my $lu = _get_userdata_for( $user => $dom );
        return 1 if $lu->{secruleengineoff};
    }

    return;
}

=head2 _get_redirects()

Given an arrayref of domains and an arrayref of hashes describing redirect
userdata for a cPanel user, it returns an arrayref of hashes describing the
redirects for each domain.

=over

=item domains

An arrayref of domains

=item cpanel_redirects

An arrayref of hashes describing redirect userdata similar to the following:

[
    {
        docroot   => '/home/foo/public_html',
        domain    => '.*',
        kind      => 'rewrite',
        matchwww  => 1,
        opts      => 'L',
        sourceurl => '/uri',
        targeturl => 'https://domain.tld',
        type      => 'permanent',
        wildcard  => 0,
    },
]

=back

=head3 RETURNS

An arrayref of hashes describing the redirects that need to be configured that
will appear similar to the following:

[
    {
        flag        => 'redirect',
        replacement => 'https://domain.tld/$1',
        regex       => '^\\/uri\\/?(.*)$',
    },
    {
        flag        => 'permanent',
        replacement => 'https://domain.tld',
        regex       => '^\\/uri$',
    },
]

=cut

sub _get_redirects {
    my ( $domains, $cpanel_redirects ) = @_;

    my %domains;
    @domains{ @{$domains} } = ();

    my @applicable_redirects;
    for my $redirect ( @{$cpanel_redirects} ) {
        next unless exists $domains{ $redirect->{domain} } || $redirect->{domain} eq '.*';

         # Ensure that $redirect->{domain} correctly references the domain
        my $redirect_domain = $redirect->{'domain'};

        # Skip redirects for domains that are proxied
        if (exists $apache2nginx_proxied_domains{$redirect_domain}) {
            warn "Excluding redirects for proxied domain '$redirect_domain'.\n";
            next;
        }

        if ( $redirect->{domain} ne '.*' ) {
            if ( $redirect->{targeturl} =~ m{^(?:[A-Za-z0-9\+_\.\-\:]+)?//(?:www\.)?\Q$redirect->{domain}\E(?:$|/|\?|\#)} ) {
                warn "Skipping circular redirect for “$redirect->{domain}” to “$redirect->{targeturl}”\n";
                next;
            }
        }

        my %res;
        if ( $redirect->{statuscode} eq "301" ) {
            $res{flag} = "permanent";
        }
        elsif ( $redirect->{statuscode} eq "302" ) {
            $res{flag} = "redirect";
        }
        else {
            warn "Skipping non 301/302 redirect\n";
            next;
        }

        # sourceurl and targeturl can be anything meaning a user would take down the server (not just their site) if its invalid
        # or worse they could inject configuration into nginx. We need to ensure nginx sees them as a single string.
        # Cpanel::UTF8::Utils::quotemeta() make sense for sourceurl since its a regex
        # 78 does not have Cpanel::UTF8::Utils so we use String::UnicodeUTF8 which it does have.
        # Escaping is bad for targeturl because the slashes are literal despite it being syntactically correct ¯\_(ツ)_/¯
        if ( !Cpanel::Validate::URL::is_valid_url( $redirect->{targeturl} ) ) {
            warn "Skipping invalid targeturl “$redirect->{targeturl}”\n";
            next;
        }

        if ( $redirect->{wildcard} ) {
            $res{regex} = $redirect->{sourceurl};
            $res{regex} =~ s{/+$}{};
            $res{regex}       = '^' . String::UnicodeUTF8::quotemeta_bytes( $res{regex} ) . '\\/?(.*)$';
            $res{replacement} = $redirect->{targeturl} . '$1';
        }
        else {
            $res{regex}       = '^' . String::UnicodeUTF8::quotemeta_bytes( $redirect->{sourceurl} ) . '$';
            $res{replacement} = $redirect->{targeturl};
        }

        push @applicable_redirects, \%res;
    }

    return \@applicable_redirects;
}

=head2 _get_basic_auth()

Returns a hashref containing directory privacy information for the domain whose
docroot is being processed.

=over

=item user

A valid cPanel username

=item docroot

The docroot for the domain that is being processed

=item cpanel_password_protected_directories

A hashref of directory privacy userdata similar to the following:

{
    '/public_html' => {
        '_htaccess_mtime' => 1234,
        'realm_name'      => 'docroot',
    },
    '/public_html/dir' => {
        '_htaccess_mtime' => 5678,
        'realm_name'      => 'dir',
    },
}

=back

=head3 RETURNS

A hashref of directory privacy information similar to the following:

{
    realm_name      => 'docroot',
    auth_file       => '/home/foo/.htpasswds/public_html/passwd',
    _htaccess_mtime => 1234,
    locations       => {
        '/sub' => {
            'auth_file'  => '/home/foo/.htpasswds/public_html/sub/passwd',
            'realm_name' => 'sub',
        },
    },
}

=cut

sub _get_basic_auth {
    my ( $user, $docroot, $cpanel_password_protected_directories ) = @_;

    my $docroot_rel = $docroot;
    my $homedir     = _get_homedir($user);
    $docroot_rel =~ s{\Q$homedir\E}{};
    my $basic_auth = $cpanel_password_protected_directories->{$docroot_rel};

    my $auth_file_from;
    if ( !$basic_auth ) {
        my $docroot_rel_copy = $docroot_rel;
        while ($docroot_rel_copy) {
            $docroot_rel_copy =~ s{/[^/]+$}{};
            if ( exists $cpanel_password_protected_directories->{$docroot_rel_copy} ) {
                $basic_auth     = $cpanel_password_protected_directories->{$docroot_rel_copy};
                $auth_file_from = $docroot_rel_copy;
                last;
            }
        }
    }

    $basic_auth ||= $cpanel_password_protected_directories->{""} || undef;
    if ($basic_auth) {
        $basic_auth->{auth_file} =
            $auth_file_from                                        ? "$homedir/.htpasswds$auth_file_from/passwd"
          : $cpanel_password_protected_directories->{$docroot_rel} ? "$homedir/.htpasswds$docroot_rel/passwd"
          :                                                          "$homedir/.htpasswds/passwd";
    }

    $basic_auth->{locations} = {};
    for my $dir ( keys %{$cpanel_password_protected_directories} ) {
        my $abs = "$homedir$dir";
        if ( $abs =~ m{^\Q$docroot\E(/.+)$} ) {
            my $loc = $1;
            $basic_auth->{locations}{$loc} = {
                auth_file  => "$homedir/.htpasswds$docroot_rel$loc/passwd",
                realm_name => $cpanel_password_protected_directories->{$dir}{realm_name},
            };
        }
    }

    return $basic_auth;
}

my $wordpress_lu;

sub _get_wp_uapi {
    my ($user) = @_;

    my $uapi = Cpanel::SafeRun::Object->new(
        program => "/usr/bin/uapi",
        args    => [ "--output=json", "--user=$user", "WordPressInstanceManager", "get_instances" ],
    );

    warn "Could not determine managed wordpress instances for $user\n" if $uapi->CHILD_ERROR();

    return eval { Cpanel::JSON::Load( $uapi->stdout() ) } || {};
}

my $wp_toolkit_list;

sub _get_wp_toolkit_list {
    print "Gathering wordpress data...\n";
    my $run = Cpanel::SafeRun::Object->new(
        program => $wp_toolkit_bin,
        args    => [ '--list', '-format', 'json' ],
    );

    warn 'Could not determine managed wordpress instances via wordpress toolkit' if $run->CHILD_ERROR();
    print "...done gathering wordpress data\n";

    return eval { Cpanel::JSON::Load( $run->stdout() ) } || [];
}

sub _get_wp_toolkit_list_for_user {
    my ( $user, $docroot, $res ) = @_;

    $wp_toolkit_list ||= _get_wp_toolkit_list();

    my ( @list, %non_docroot_uris );
    foreach my $wordpress_instance (@$wp_toolkit_list) {
        if ( $wordpress_instance->{fullPath} =~ m{/\Q$user\E/} ) {
            if ( $wordpress_instance->{fullPath} eq $docroot ) {
                $res->{docroot_install} = 1;
                next;
            }

            my $uri_from_path = $wordpress_instance->{fullPath};
            $uri_from_path =~ s{^\Q$docroot\E/}{};

            my $uri_from_siteurl = $wordpress_instance->{siteUrl};
            $uri_from_siteurl =~ s{^https?://[^/]+/}{};

            if ( $uri_from_path eq $uri_from_siteurl ) {
                $non_docroot_uris{$uri_from_path} = 1;
                next;
            }
        }

        push @list, $wordpress_instance;
    }

    # Do it this way in case there are duplicate entries in $wp_toolkit_list
    @{ $res->{non_docroot_uris} } = keys %non_docroot_uris;

    # Remove the entries in $res from the list since they will only match 1 time
    $wp_toolkit_list = \@list;

    return $res;
}

=head2 _get_wordpress_info()

Given a user and a docroot, it gathers information the locations of wordpress
installs relative to the document root and returns a hashref with this info.

=over

=item user

A cPanel username

=item docroot

The path to the user's docroot

=back

=head3 RETURNS

A hashref that appears similar to the following:

{
    docroot_install  => 1,
    non_docroot_uris => [
        'foo',
        'bar',
    ],
}

=cut

our %wordpress_info;

sub _get_wordpress_info {
    my ( $user, $docroot ) = @_;

    return $wordpress_info{$docroot} if exists $wordpress_info{$docroot};

    my $docroot_underscore = $docroot;
    $docroot_underscore =~ s{/}{_}g;
    my $cache_file = "$wordpress_cache_dir/${user}_${docroot_underscore}_wordpress_info.json";

    # Returns undef if the cache_file fails to load or does not contain the required keys
    $wordpress_info{$docroot} = _get_wordpress_info_from_cache($cache_file) if _is_wordpress_info_cache_valid($cache_file);
    return $wordpress_info{$docroot}                                        if exists $wordpress_info{$docroot};

    my $res = {
        docroot_install  => 0,
        non_docroot_uris => [],
    };

    if ( -e $wp_toolkit_bin ) {
        $wordpress_info{$docroot} = _get_wp_toolkit_list_for_user( $user, $docroot, $res );
    }
    elsif ( -e '/usr/local/cpanel/Cpanel/API/WordPressInstanceManager.pm' ) {
        $wordpress_lu->{$user} ||= _get_wp_uapi($user);
        $wordpress_info{$docroot} = _get_wordpress_info_from_wpmanager( $docroot, $wordpress_lu->{$user}{result}{data}{instances}, $res );
    }
    else {
        $wordpress_info{$docroot} = $res;
    }

    # cache wordpress info
    _ensure_wordpress_info_cache_directory();
    _write_json( $cache_file, $res );

    return $wordpress_info{$docroot};
}

sub _get_wordpress_info_from_wpmanager {
    my ( $docroot, $wordpress_instances, $res ) = @_;

    # paths passed in and from API call do not have trailing slash, if that changes we could normalize them
    for my $wp_instance (@$wordpress_instances) {
        if ( length $wp_instance->{rel_path} ) {
            my $instance_docroot = $wp_instance->{full_path};
            $instance_docroot =~ s{/\Q$wp_instance->{rel_path}\E$}{};
            if ( $instance_docroot eq $docroot ) {
                push @{ $res->{non_docroot_uris} }, $wp_instance->{rel_path};
            }
        }
        else {
            if ( $wp_instance->{full_path} eq $docroot ) {
                $res->{docroot_install} = 1;
            }
        }
    }

    return $res;
}

sub _ensure_wordpress_info_cache_directory {
    mkdir $wordpress_cache_dir;
    return;
}

=head2 _is_wordpress_info_cache_valid()

Returns 1 or 0 depending on if the cache file is valid or not.  NOTE: it will
always return 0 if the '/var/cpanel/nginx_disable_wordpress_info_cache' touch
file is in place.

=over

=item cache_file

The path of the cache file to check

=back

=cut

sub _is_wordpress_info_cache_valid {
    my ($cache_file) = @_;

    return 0 if -e $disable_wordpress_info_cache_file;

    my $now   = time();
    my $ttl   = 18000;                      # Cache it for 5 days
    my $mtime = ( stat($cache_file) )[9];

    return ( -s $cache_file && $mtime > ( $now - $ttl ) ) ? 1 : 0;
}

=head2 _get_wordpress_info_from_cache()

Loads the given cache file, and returns the data as a hashref.  It will warn and
return undef if the given cache file is not valid json or if it is missing the
required keys.

=over

=item cache_file

The path to the cache file to load

=back

=cut

sub _get_wordpress_info_from_cache {
    my ($cache_file) = @_;

    my $res;
    unless ( eval { $res = Cpanel::JSON::LoadFile($cache_file); 1; } ) {
        warn "Failed to load cache file:  ‘$cache_file’\n";
        return;
    }

    if ( !exists $res->{docroot_install} || !exists $res->{non_docroot_uris} ) {
        warn "The cache file ‘$cache_file’ has missing data\n";
        return;
    }

    return $res;
}

=head2 _validate_user_arg()

Validate that the given username is a valid cPanel user

=over

=item user

A cPanel user to validate

=back

=head3 RETURNS

Returns 1 if the give username is valid.  Dies otherwise.

=cut

sub _validate_user_arg {
    my ( $app, $user ) = @_;

    _bail( $app, "The user argument is missing." ) if !$user;

    my $user_lookup = _get_user_domains();
    _bail( $app, "The given user is not a cPanel user.\n" ) if !$user_lookup->{$user};

    return 1;
}

sub _get_cmd {
    return $cmds;
}

=head2 _bail()

Handles unclean script exits.  For example, if an invalid username is given.

=cut

sub _bail {
    my ( $app, $msg ) = @_;
    chomp($msg);

    die "$msg\n" if $ENV{ __PACKAGE__ . "::bail_die" };    # for API calls, otherwise:

    warn "$msg\n";
    $app->help();
    exit(1);                                               # there is no return()ing from this lol
}

sub _delete_glob {
    my ($glob) = @_;

    for my $item ( File::Glob::csh_glob($glob) ) {

        # File::Path::Tiny::rm does not delete files
        if ( -l $item || -f _ ) {
            unlink($item);
        }
        elsif ( -d $item ) {
            File::Path::Tiny::rm($item);
        }
    }

    return;
}

=head2 _get_cpconf_hr()

Returns a hashref containing the cPanel settings from
'/var/cpanel/cpanel.config'.  The result of this sub is cached and the cached
result will be returned after the first call.

=cut

our $cpconf_hr;

sub _get_cpconf_hr {
    $cpconf_hr ||= Cpanel::Config::LoadCpConf::loadcpconf();
    return $cpconf_hr;
}

=head2 _get_nginx_bin()

Returns the path to the nginx binary.  Dies if the nginx does not exist or is
not executable.

=cut

sub _get_nginx_bin {
    my $nginx_bin = '/usr/sbin/nginx';

    # This executable is provided by the ea-nginx RPM so this should never happen
    die "Could not find an executable nginx binary\n" unless -x $nginx_bin;
    return $nginx_bin;
}

=head2 _attempt_to_fix_syntax_errors()

Parses the given output for known nginx configuration syntax errors such as
duplicate keys and attempts to fix them.  Returns 0 or 1 depending on if it
resolved an issue or not.

=over

=item combined_output

A multiline string to parse

=back

=head3 RETURNS

Returns 1 if it resolved an issue.  Returns 0 otherwise.

=cut

sub _attempt_to_fix_syntax_errors {
    my ($combined_output) = @_;
    die "_attempt_to_fix_syntax_errors: ‘combined_output’ arg is required\n"
      unless defined $combined_output;

    my $resolved_issue = 0;
    my @output_lines   = split /\n/, $combined_output;
    foreach my $output_line (@output_lines) {

        # For example:
        # nginx: [emerg] "client_max_body_size" directive is duplicate in /etc/nginx/conf.d/ea-nginx.conf:19
        # use non greedy modifier here to ensure we do not match more than we intend to
        if ( $output_line =~ m{^nginx: \[emerg\] "(.*?)" directive is duplicate in (.*?):([0-9]+)$} ) {
            my $key       = $1;
            my $file_path = $2;
            my $line_num  = $3;

            die "‘$file_path’ reported an error on line $line_num.  However, ‘$file_path’ does not exist on the system\n"
              unless -s $file_path;

            my $file       = Path::Tiny::path($file_path);
            my @file_lines = $file->lines();
            chomp(@file_lines);
            $line_num--;    # avoid off by 1 error (line 42 in the file is item 41 in the array)

            # For example:
            # nginx_key value_for_key;
            if ( $file_lines[$line_num] =~ m{^\s*\Q$key\E.*;$} ) {
                $resolved_issue = 1;
                $file_lines[$line_num] = '# ' . $file_lines[$line_num];
                $line_num++;
                print "Line $line_num in the file ‘$file_path’ was commented out since another entry exists for the key:  $key\n";
            }

            $file->spew( join( "\n", @file_lines ) );
            last;    # nginx -t only reveals the first issue it encounters
        }

        elsif ( $output_line =~ m{^nginx: \[emerg\].*?in (/etc/nginx/conf.d/users/[a-z0-9]{1,16}\.conf):([0-9]+)$} ) {
            my $file_path = $1;
            my $line_num  = $2;

            die "‘$file_path’ reported an error on line $line_num.  However, ‘$file_path’ does not exist on the system\n"
              unless -s $file_path;

            unlink $file_path;
            die "Unable to remove $file_path\n" if -f $file_path;
            $resolved_issue = 1;
            print "‘$file_path’ was removed due to a configuration issue:  $output_line\n";
            last;    # nginx -t only reveals the first issue it encounters
        }
    }

    return $resolved_issue;
}

=head2 _get_num_total_domains()

Returns an integer representing the total number of domains hosted on the
system.

=cut

sub _get_num_total_domains {
    return unless ( eval { require Cpanel::Config::LoadUserDomains::Count; 1; } );
    return Cpanel::Config::LoadUserDomains::Count::countuserdomains();
}

=head2 _get_user_domains()

This is a wrapper around 'Cpanel::Config::LoadUserDomains::loaduserdomains'. See
the POD for loaduserdomains for details. The result of this sub is cached and
the cached result will be returned after the first call.

=head3 RETURNS

This function returns a hash ref with a key/value mapping of users to domains.

=cut

my $user_domains;

sub _get_user_domains {
    $user_domains //= Cpanel::Config::LoadUserDomains::loaduserdomains( undef, 0, 1 );
    return $user_domains;
}

=head2 _rebuild_and_restart_apache()

Rebuild the apache configuration and restart the service

=cut

sub _rebuild_and_restart_apache {
    my $run = Cpanel::SafeRun::Object->new(
        program => '/usr/local/cpanel/scripts/rebuildhttpdconf',
        args    => [],
    );

    if ( $run->CHILD_ERROR() ) {
        my $out = $run->stdout() . $run->stderr();
        warn "Failed to rebuild apache configuration:  $out\n";
    }

    Cpanel::HttpUtils::ApRestart::BgSafe::restart();
    return;
}

=head2 _update_user_configs_in_parallel_mode()

Build the configuration for all users asynchronously.

=over

=item global_config_data

A hashref containing data that should be treated as read only that all users
can use to build their configs with.

=back

=head3 RETURNS

A hashref of users that had errors during the configuration process that should
be similar to the following:

{
    user1 => 'error message',
    user2 => 'another error message',
}

OR undef if all of the users were configured successfully.

=cut

sub _update_user_configs_in_parallel_mode {
    my ($global_config_data) = @_;

    my $user_lookup = _get_user_domains();
    my %errors      = mce_loop {
        my ( $mce, $chunk_ref ) = @_;

        my $err_href = _process_users( $chunk_ref, $global_config_data );
        MCE->gather( $_, $err_href->{$_} ) for ( keys %$err_href );
    }
    keys %$user_lookup;

    return \%errors;
}

=head2 _update_user_configs_in_serial_mode()

Build the configuration for all users synchronously.

=over

=item global_config_data

A hashref containing data that should be treated as read only that all users
can use to build their configs with.

=back

=head3 RETURNS

A hashref of users that had errors during the configuration process that should
be similar to the following:

{
    user1 => 'error message',
    user2 => 'another error message',
}

OR undef if all of the users were configured successfully.

=cut

sub _update_user_configs_in_serial_mode {
    my ($global_config_data) = @_;

    print "Serial mode detected.  User configuration will take longer …\n";

    my $user_lookup = _get_user_domains();
    my @users       = sort keys %$user_lookup;
    my $errors      = _process_users( \@users, $global_config_data );

    return $errors;
}

=head2 _process_users()

Accepts an arrayref of users and writes the configuration for each user.

=over

=item users

A arrayref of users to process

=item global_config_data

A hashref containing data that should be treated as read only that all users
can use to build their configs with.

=back

=head3 RETURNS

A hashref of users to error messages for any user that configuration fails for.

=cut

sub _process_users {
    my ( $users, $global_config_data ) = @_;

    my %errors;
    foreach my $usr (@$users) {
        eval { _write_user_conf( $usr, $global_config_data ); };
        $errors{$usr} = $@ if $@;
    }

    return \%errors;
}

=head2 _update_for_custom_configs()

Should only be called by run().  The idea here is to allow clients to place a
file in the $custom_settings_dir prior to installing the ea-nginx RPM.  Then,
this script will pick up the file and place it in the proper location on first
run.  After which, these subs should become no-ops unless another overwrite
file is place in the $custom_settings_dir.  Currently, the following two files
are supported:

settings.json
cache.json

To add a new file, create a sub to handle moving said file to its proper
location, then call the sub from _update_for_custom_configs()

=cut

sub _update_for_custom_configs {

    _update_nginx_settings_config_file();
    _update_nginx_cache_config_file();

    return;
}

=head2 _update_nginx_settings_config_file()

This reads in a settings.json file from the $custom_settings_dir and moves it
to '/etc/nginx/ea-nginx/settings.json'.  NOTE: the following keys are not
honored since they are server specific:

apache_port
apache_port_ip
apache_ssl_port
apache_ssl_port_ip

=cut

sub _update_nginx_settings_config_file {
    my $custom_settings_file = "$custom_settings_dir/settings.json";
    my $custom_settings_path = path($custom_settings_file);
    if ( $custom_settings_path->exists ) {

        # do not allow specifying apache_ ones
        my $new = Cpanel::JSON::LoadFile($custom_settings_file);
        my $cur = _get_settings_hr();
        $settings_hr = undef;    # clear _get_settings_hr() cache since we are changing settings
        for my $key (qw(apache_port apache_port_ip apache_ssl_port apache_ssl_port_ip)) {
            $new->{$key} = $cur->{$key};
        }
        _write_json( $custom_settings_file, $new );

        # Do not use Path::Tiny until 110 or greater only is supported
        # See CPANEL-41646 for details
        # $custom_settings_path->move($settings_file);
        Cpanel::FileUtils::Move::safemv( '-fv', $custom_settings_file, $settings_file );
    }

    return;
}

=head2 _update_nginx_cache_config_file()

This reads in a cache.json file from the $custom_settings_dir and moves it to
'/etc/nginx/ea-nginx/cache.json'.

=cut

sub _update_nginx_cache_config_file {
    my $custom_cache_file = "$custom_settings_dir/cache.json";
    my $custom_cache_path = path($custom_cache_file);

    # Do not use Path::Tiny until 110 or greater only is supported
    # See CPANEL-41646 for details
    # $custom_cache_path->move($cache_file) if $custom_cache_path->exists;
    Cpanel::FileUtils::Move::safemv( '-fv', $custom_cache_file, $cache_file ) if $custom_cache_path->exists;

    return;
}

=head2 _get_global_config_data()

Gathers data that should be treated as read only and will be the same for every
user when rendering the user's config file.

This sub will get called directly in config() so that the data it gathers is
cloned to every fork and thus will be the same in every fork when building in
parallel mode (MCE::Loop).

=head3 RETURNS

A hashref containing data that should be considered read only

=cut

sub _get_global_config_data {
    my %global_config_data = (
        domain_ips   => _get_domain_ips(),
        ipv6         => _has_ipv6(),
        http2        => _wants_http2(),
        logging      => _get_logging_hr(),
        e4c          => Cpanel::EA4::Conf->instance->as_hr(),
        cur_settings => _get_settings_hr(),
        standalone   => _is_standalone(),
        cpconf       => _get_cpconf_hr(),
    );

    return \%global_config_data;
}

=head2 _populate_wordpress_cache( { all => 1 } )

This populates the wordpress cache before the script starts forking so that a
potentially expensive call to wp-toolkit will only ever be called a maximum of
one time

=over

=item args

A hashref containing the known args as listed below

=over

=item all => 1

This tells the sub to populate the wordpress cache for all users

=item user => $user

This tells the sub to only populate the wordpress cache for the given user

=back

=back

=cut

sub _populate_wordpress_cache {
    my ($args) = @_;

    die "_populate_wordpress_cache: invalid args passed.  Either ‘all’ or ‘user’ is required\n"
      if ( !$args->{all} && !$args->{user} );

    return _populate_wordpress_cache_for_all_users()           if $args->{all};
    return _populate_wordpress_cache_for_user( $args->{user} ) if $args->{user};

    return;
}

sub _populate_wordpress_cache_for_all_users {
    my $user_lookup = _get_user_domains();
    foreach my $user ( sort keys %$user_lookup ) {
        _populate_wordpress_cache_for_user($user);
    }
    return;
}

sub _populate_wordpress_cache_for_user {
    my ($user) = @_;

    my $docroots = _get_docroots_for($user);
    foreach my $domain ( keys %$docroots ) {
        _get_wordpress_info( $user, $docroots->{$domain} );
    }

    return;
}

=head2 _get_docroots_for($user)

=over

=item user

A valid cPanel username

=back

=head3 RETURNS

A hashref where the keys are all the domains owned by the given user and the
values represent the docroot for the given domain

=cut

sub _get_docroots_for {
    my ($user) = @_;
    $docroots->{$user} ||= { Cpanel::DomainLookup::DocRoot::getdocroots($user) };
    return $docroots->{$user};
}

=head2 _get_domain_ips()

Maps IPs to the primary domain that they are associated with on the server.
This allows us to configure the domain on only one server block and to the
server block that the user desires according to metadata / apache.

=head3 RETURNS

A hashref that maps IPs to domains.

=cut

sub _get_domain_ips {
    my $primary_vhosts_obj = Cpanel::HttpUtils::Vhosts::PrimaryReader->new();

    my %domain_ips;
    my $user_lookup = _get_user_domains();
    for my $usr ( sort keys %$user_lookup ) {
        my $domain = Cpanel::Config::userdata::Load::load_userdata_main($usr)->{main_domain};
        my $ip     = Cpanel::DomainIp::getdomainip($domain);

        if ( $ip && !exists $domain_ips{$ip} ) {
            if ( Cpanel::DIp::IsDedicated::isdedicatedip($ip) || $domain eq $primary_vhosts_obj->get_primary_non_ssl_servername($ip) ) {
                $domain_ips{$ip} = $domain;
            }
        }
    }

    return \%domain_ips;
}

=head2 _wants_user_id()

Returns whether the server admin wants the user id of the server block that was
hit logged or not.  Caches the result for subsequent calls.

=head3 RETURNS

Returns 1 or 0 depending on the existence of the touch file

=cut

our $wants_user_id;

sub _wants_user_id {
    return $wants_user_id if defined $wants_user_id;
    $wants_user_id = -e $user_id_file ? 1 : 0;
    return $wants_user_id;
}

1;

Zerion Mini Shell 1.0