\!/ KyuuKazami \!/

Path : /scripts/
Upload :
Current File : //scripts/fix_mail_subdomain_userdata

#!/usr/local/cpanel/3rdparty/bin/perl

# cpanel - scripts/fix_mail_subdomain_userdata
#                                                    Copyright 2016 cPanel, Inc.
#                                                           All rights Reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cpanel license. Unauthorized copying is prohibited

package scripts::fix_mail_subdomain_userdata;

use strict;
use warnings;

use Cpanel::Autodie                             ();
use Cpanel::Config::CpUserGuard                 ();
use Cpanel::Config::userdata::Guard             ();
use Cpanel::Config::userdata::Load              ();
use Cpanel::Config::Users                       ();
use Cpanel::Debug                               ();
use Cpanel::Exception                           ();
use Cpanel::Fcntl                               ();
use Cpanel::Mkdir                               ();
use Cpanel::Output::Formatted::Terminal         ();
use Cpanel::Output::Formatted::TimestampedPlain ();
use Cpanel::Output::Multi                       ();
use Cpanel::PIDFile                             ();
use Cpanel::Rand::Get                           ();
use Cpanel::ServerTasks                         ();
use Cpanel::Time::ISO                           ();
use Cpanel::Userdomains                         ();

use Getopt::Long ();
use Pod::Usage   ();

use Try::Tiny;

=encoding utf-8

=head1 NAME

scripts::fix_mail_subdomain_userdata

=head1 SYNOPSIS

    fix_mail_subdomain_userdata ( --dry-run | --help )

This script will iterate through the users on the system and look for invalid 'mail.' domains added
as parked domains, addon domains, or subdomains and remove those erroneously added domains.

An audit log will be generated by this action for later review. That log will be located under
F</var/cpanel/logs/audit/>

If C<--dry-run> is specified no actual changes will be committed on the system, but a report of what
the script would do will be printed to the terminal. No audit log is generated with this option.

=cut

our $_PID_FILE;
our $AUDIT_LOG_PATH = '/var/cpanel/logs/audit';
our $AUDIT_LOG_NAME = 'fix_mail_subdomain_userdata_audit';

BEGIN {
    $_PID_FILE = sprintf( "/var/cpanel/%s.pid", __PACKAGE__ );
}

__PACKAGE__->command_line(@ARGV) unless caller();

sub command_line {
    my ( $class, @args ) = @_;

    die "This must run as root!\n" if $>;

    my ( $dry_run, $help );

    if (
        !Getopt::Long::GetOptionsFromArray(
            \@args,
            'dry-run|n' => \$dry_run,
            help        => \$help,
        )
        || $help
    ) {
        print _help();
        return;
    }

    Cpanel::PIDFile->do(
        $_PID_FILE,
        sub {
            my $script = $class->new( dry_run => $dry_run );
            $script->process_users();
        },
    );

    return;
}

sub _help {
    my $msg = shift;

    my $val;
    open my $wfh, '>', \$val or die "Failed to open to a scalar: $!";

    Pod::Usage::pod2usage(
        -exitval   => 'NOEXIT',
        -message   => $msg,
        -verbose   => 1,
        -noperldoc => 1,
        -output    => $wfh,
    );

    return $val;

}

sub new {
    my ( $class, %OPTS ) = @_;

    my $self = bless {
        _dry_run => $OPTS{dry_run} ? 1 : 0,
    }, $class;
    $self->_initialize_logger();

    return $self;
}

sub process_users {
    my ($self) = @_;

    my $rebuild_apache = 0;

    my @users = Cpanel::Config::Users::getcpusers();
    for my $user ( sort @users ) {
        try {
            my $files_updated = $self->_process_user($user);
            $rebuild_apache ||= $files_updated;
        }
        catch {
            $self->error("There was an error processing the user '$user': $_");
        };
    }

    if ($rebuild_apache) {
        Cpanel::ServerTasks::queue_task( ['ApacheTasks'], 'build_apache_conf', 'apache_restart' );
        Cpanel::Userdomains::updateuserdomains();
    }

    return;
}

sub _process_user {
    my ( $self, $user ) = @_;
    my $user_userdata_dir = $Cpanel::Config::userdata::Constants::USERDATA_DIR . "/$user";
    if ( !-d $user_userdata_dir ) {
        $self->warn("Unable to find the userdata directory for the user $user\n");
        return undef;
    }

    my $cpuser_guard        = Cpanel::Config::CpUserGuard->new($user);
    my $cpuser_data         = $cpuser_guard->{data};
    my %mail_cpuser_domains = map { $_ => 1 } grep { index( $_, 'mail.' ) == 0 } @{ $cpuser_data->{DOMAINS} };

    my $userdata_guard      = Cpanel::Config::userdata::Guard->new($user);
    my $userdata            = $userdata_guard->data();
    my %mail_parked_domains = map { $_ => 1 } grep { index( $_, 'mail.' ) == 0 } @{ $userdata->{parked_domains} };
    my %mail_addon_domains  = map { $_ => 1 } grep { index( $_, 'mail.' ) == 0 } keys %{ $userdata->{addon_domains} };
    my %sub_domains         = map { $_ => 1 } @{ $userdata->{sub_domains} };
    my $main_domain         = $userdata->{main_domain};

    my %parked_to_remove;
    my %files_to_remove;
    my %subs_to_remove;
    my %addons_to_remove;
    my @ok_domains;
    for my $parked ( keys %mail_parked_domains ) {
        $self->_process_domain(
            user              => $user,
            domain            => $parked,
            parent_domain     => $main_domain,
            sub_domains       => \%sub_domains,
            files_to_remove   => \%files_to_remove,
            subs_to_remove    => \%subs_to_remove,
            domains_to_remove => \%parked_to_remove,
            ok_domains        => \@ok_domains,
        );
    }

    for my $addon ( keys %mail_addon_domains ) {
        $self->_process_domain(
            user              => $user,
            domain            => $addon,
            parent_domain     => $userdata->{addon_domains}{$addon},
            sub_domains       => \%sub_domains,
            files_to_remove   => \%files_to_remove,
            subs_to_remove    => \%subs_to_remove,
            domains_to_remove => \%addons_to_remove,
            ok_domains        => \@ok_domains,
        );
    }

    my $data_needs_to_be_saved = 0;
    if ( keys %parked_to_remove ) {
        $userdata->{parked_domains} = [ grep { !$parked_to_remove{$_} } @{ $userdata->{parked_domains} } ];
        $data_needs_to_be_saved = 1;
    }

    if ( keys %addons_to_remove ) {
        delete @{ $userdata->{addon_domains} }{ keys %addons_to_remove };
        $data_needs_to_be_saved = 1;
    }

    if ( keys %subs_to_remove ) {
        $userdata->{sub_domains} = [ grep { !$subs_to_remove{$_} } @{ $userdata->{sub_domains} } ];
        $data_needs_to_be_saved = 1;
    }

    my @file_removal_errors;
    if ( !$self->{_dry_run} && keys %files_to_remove ) {
        for my $key ( sort keys %files_to_remove ) {
            try {
                Cpanel::Autodie::unlink( $files_to_remove{$key} );
                my $cache_file = $files_to_remove{$key} . ".cache";
                Cpanel::Autodie::unlink($cache_file) if -e $cache_file;
            }
            catch {
                push @file_removal_errors, Cpanel::Exception::get_string_no_id($_);
            };
        }
    }

    if ($data_needs_to_be_saved) {
        $cpuser_data->{DOMAINS} = [ grep { !$subs_to_remove{$_} && !$parked_to_remove{$_} && !$addons_to_remove{$_} } @{ $cpuser_data->{DOMAINS} } ];
    }

    my ( $failure, $rebuild_apache );
    if ( $data_needs_to_be_saved && !$self->{_dry_run} ) {
        if ( $userdata_guard->save() ) {
            if ( !$cpuser_guard->save() ) {
                $cpuser_guard->abort();

                # Should we rollback here?
                $failure = "The cpuser data for $user failed to save. Not all changes committed.";
            }
            $rebuild_apache = 1;
        }
        else {
            $userdata_guard->abort();
            $cpuser_guard->abort();
            $failure = "The main userdata file for $user failed to save. Only the extraneous userdata file removals were committed.";
        }
    }
    else {
        $userdata_guard->abort();
        $cpuser_guard->abort();
        $failure = "Dry run option specified. No changes were committed.\n" if $self->{_dry_run};
    }

    if ( !length $failure ) {
        $self->_generate_report(
            user                => $user,
            parked_to_remove    => \%parked_to_remove,
            subs_to_remove      => \%subs_to_remove,
            addons_to_remove    => \%addons_to_remove,
            files_to_remove     => \%files_to_remove,
            file_removal_errors => \@file_removal_errors,
            ok_domains          => \@ok_domains,
        );
    }
    else {
        $self->_generate_failure_report(
            user                => $user,
            parked_to_remove    => \%parked_to_remove,
            subs_to_remove      => \%subs_to_remove,
            addons_to_remove    => \%addons_to_remove,
            files_to_remove     => \%files_to_remove,
            file_removal_errors => \@file_removal_errors,
            ok_domains          => \@ok_domains,
            failure             => $failure,
        );
    }

    return $rebuild_apache ? 1 : 0;
}

sub _process_domain {
    my ( $self, %OPTS ) = @_;

    my ( $user, $domain, $parent_domain, $sub_domains ) = @OPTS{qw( user domain parent_domain sub_domains )};

    # domains_to_remove is either addons_to_remove or parked_to_remove
    my ( $files_to_remove, $subs_to_remove, $domains_to_remove, $ok_domains ) = @OPTS{qw( files_to_remove subs_to_remove domains_to_remove ok_domains )};

    # Neither addon or parked domains should have a userdata file
    my $userdata_file = Cpanel::Config::userdata::Load::user_has_domain( $user, $domain ) ? Cpanel::Config::userdata::Load::get_userdata_file_for_domain( $user, $domain ) : undef;

    # sometimes the domain is also added as a subdomain (this only has happened with addons in testing, but just in case we do it for both)
    my $is_also_subdomain = $sub_domains->{$domain} ? 1 : 0;

    my $parent_domain_userdata = _get_userdata_for_user_and_domain_if_exists( $user, $parent_domain );
    if ( $parent_domain_userdata->{serveralias_map}{"mail.$domain"} ) {

        # The parent domain has mail.mail.domain.tld in the aliases then it's most likely an actual parked domain
        $files_to_remove->{$domain} = $userdata_file if length $userdata_file;
        $subs_to_remove->{$domain} = 1 if $is_also_subdomain;

        push @$ok_domains, $domain;
    }
    else {
        $files_to_remove->{$domain} = $userdata_file if length $userdata_file;
        $subs_to_remove->{$domain} = 1 if $is_also_subdomain;

        $domains_to_remove->{$domain} = 1;
    }

    return;
}

sub _generate_report {
    my ( $self, %OPTS ) = @_;

    my ( $user, $parked_to_remove, $subs_to_remove, $addons_to_remove, $files_to_remove, $file_removal_errors, $ok_domains ) = @OPTS{qw( user parked_to_remove subs_to_remove addons_to_remove files_to_remove file_removal_errors ok_domains )};

    my $report = "-------------------------------------------------------\n";
    $report .= "USER: $user\n";
    $report .= "STATUS: SUCCEEDED\n";
    $report .= "Parked Domains Removed:\n\t" . ( join( "\n\t", sort keys %$parked_to_remove ) || "None." );
    $report .= "\n\nSubdomains Removed:\n\t" . ( join( "\n\t", sort keys %$subs_to_remove ) || "None." );
    $report .= "\n\nAddon Domains Removed:\n\t" . ( join( "\n\t", sort keys %$addons_to_remove ) || "None." );
    $report .= "\n\nUserdata Files Removed:\n\t" . ( join( "\n\t", map { $files_to_remove->{$_} } sort keys %$files_to_remove ) || "None." );
    if ( scalar @$file_removal_errors ) {
        chomp(@$file_removal_errors);
        $report .= "\n\nErrors removing userdata files:\n\t" . join( "\n\t", @$file_removal_errors );
    }
    $report .= "\n\nOther mail domains found, but not removed:\n\t" . ( join( "\n\t", sort @$ok_domains ) || "None." );
    $report .= "\n-------------------------------------------------------\n";

    $self->info($report);

    return;
}

sub _generate_failure_report {
    my ( $self, %OPTS ) = @_;

    my ( $user, $parked_to_remove, $subs_to_remove, $addons_to_remove, $files_to_remove, $file_removal_errors, $failure, $ok_domains ) = @OPTS{qw( user parked_to_remove subs_to_remove addons_to_remove files_to_remove file_removal_errors failure ok_domains )};

    my $report = "-------------------------------------------------------\n";
    $report .= "USER REPORT: $user\n";
    $report .= "STATUS: FAILED\n";
    $report .= "FAILURE: $failure\n";
    $report .= "Invalid Parked Domains Found:\n\t" . ( join( "\n\t", sort keys %$parked_to_remove ) || "None." );
    $report .= "\n\nInvalid Subdomains Found:\n\t" . ( join( "\n\t", sort keys %$subs_to_remove ) || "None." );
    $report .= "\n\nInvalid Addon Domains Found:\n\t" . ( join( "\n\t", sort keys %$addons_to_remove ) || "None." );
    $report .= "\n\nUserdata Files Removed:\n\t" . ( join( "\n\t", map { $files_to_remove->{$_} } sort keys %$files_to_remove ) || "None." );

    if ( scalar @$file_removal_errors ) {
        chomp(@$file_removal_errors);
        $report .= "\n\nErrors removing userdata files:\n\t" . join( "\n\t", @$file_removal_errors );
    }
    $report .= "\n\nOther mail domains found that seem to be valid:\n\t" . ( join( "\n\t", sort @$ok_domains ) || "None." );
    $report .= "\n-------------------------------------------------------\n";

    $self->info($report);

    return;
}

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

    return undef if !Cpanel::Config::userdata::Load::user_has_domain( $user, $domain );

    # The 1 means to skip addon domain lookup here since we know the domain has a file and we want to know whats in that file
    my $userdata = Cpanel::Config::userdata::Load::load_userdata( $user, $domain, 1 );

    $userdata->{serveralias_map} = { map { $_ => 1 } split( m/ /, $userdata->{serveralias} ) };
    return $userdata;
}

sub info {
    my ( $self, $msg ) = @_;
    return $self->{'_output'}->out($msg);
}

sub warn {
    my ( $self, $msg ) = @_;
    return $self->{'_output'}->warn($msg);
}

sub error {
    my ( $self, $msg ) = @_;
    return $self->{'_output'}->error($msg);
}

sub _initialize_logger {
    my ($self) = @_;

    my @outputs;
    if ( !$self->{_dry_run} ) {
        try {
            Cpanel::Mkdir::ensure_directory_existence_and_mode( $AUDIT_LOG_PATH, 0700 );
            my $rand_part = Cpanel::Rand::Get::getranddata(8);
            my $iso_time  = _get_iso_time();
            my $fh;

            Cpanel::Autodie::sysopen(
                $fh,
                "$AUDIT_LOG_PATH/$AUDIT_LOG_NAME.$rand_part.$iso_time.log",
                Cpanel::Fcntl::or_flags(qw(O_WRONLY O_APPEND O_EXCL O_CREAT)),
                0600,
            );

            push @outputs, Cpanel::Output::Formatted::TimestampedPlain->new(
                filehandle       => $fh,
                timestamp_method => $self->can('_get_iso_time'),
            );
        }
        catch {
            Cpanel::Debug::log_warn( "Failed to initialize the log file for " . __PACKAGE__ . " due to an error: $_" );
        };
    }

    # We want the output to go somewhere.. output to terminal if the log file fails to initialize
    if ( _terminal_ok() || !@outputs ) {
        push @outputs, Cpanel::Output::Formatted::Terminal->new();
    }

    $self->{'_output'} = Cpanel::Output::Multi->new( output_objs => \@outputs );

    return;
}

# Mocked in tests
sub _get_iso_time {
    return Cpanel::Time::ISO::unix2iso();
}

# Mocked in tests
sub _terminal_ok { return -t \*STDIN }

1;

@KyuuKazami