| Current Path : /scripts/ |
| Current File : //scripts/smtpmailgidonly |
#!/usr/local/cpanel/3rdparty/bin/perl
package scripts::smtpmailgidonly;
# Copyright 2026 WebPros International, LLC
# All rights reserved.
# copyright@cpanel.net http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.
use strict;
use warnings;
use Cpanel::Binaries ();
use Cpanel::Chkservd ();
use Cpanel::Config::CpConfGuard ();
use Cpanel::Exim::Config::Ports ();
use Cpanel::FileUtils::TouchFile ();
use Cpanel::FileUtils::Write ();
use Cpanel::LoadFile ();
use Cpanel::OS ();
use Cpanel::PwCache ();
use Cpanel::SafeRun::Errors ();
use Cpanel::SafeRun::Object ();
use Cpanel::Services::Firewall ();
use Cpanel::Validate::IP ();
use Cpanel::Validate::IP::v4 ();
use Cpanel::YAML ();
use constant NFT_TABLE => 'cpanel_smtp_restrict';
our @PORTS;
our @RULE_TYPES;
our @RULES;
sub run {
my (@argv) = @_;
my $version = '2.4';
my $action = lc( ( grep( m/^-*(?:on|off|status|refresh|start|stop)$/i, @argv ) )[0] // '' ) || 0;
$action =~ s/^-*//g;
my $no_run_header = "$0 version $version - Copyright(C) 2020 cPanel, L.L.C.\nThis may be freely redistributed under the terms of the Artistic License.";
if ( !$action ) {
print STDERR <<"EOM";
$no_run_header
usage: $0 <on|off|status|refresh|start|stop>
EOM
return 1;
}
my $cpaneluid = ( Cpanel::PwCache::getpwnam('cpanel') )[2];
my $mailgid = ( Cpanel::PwCache::getpwnam('mail') )[3];
my $mailmangid = ( Cpanel::PwCache::getpwnam('mailman') )[3];
my $exim_alt_port = Cpanel::Chkservd::geteximport(1); #first arg allows fetch more than the first port
if ($exim_alt_port) {
foreach my $port ( split( m/\s*\,\s*/, $exim_alt_port ) ) {
$Cpanel::Exim::Config::Ports::LISTEN_PORTS{$port} = 1 if _valid_port($port);
}
}
@PORTS = sort { $a <=> $b } keys %Cpanel::Exim::Config::Ports::LISTEN_PORTS;
@RULE_TYPES = (
{ 'table' => 'nat', 'target' => 'RETURN', 'method' => '-I' },
{ 'table' => '', 'target' => 'ACCEPT', 'method' => '-I' }
);
@RULES = (
{ 'type' => 'uid', 'value' => 0, 'name' => 'root' }, #aka root
$cpaneluid ? { 'type' => 'uid', 'value' => $cpaneluid, 'name' => 'cpanel', 'args' => [ '-d', '127.0.0.1' ] } : (),
$mailgid ? { 'type' => 'gid', 'value' => $mailgid, 'name' => 'mail' } : (),
$mailmangid ? { 'type' => 'gid', 'value' => $mailmangid, 'name' => 'mailman' } : ()
);
# for future expansion
if ( -e '/var/cpanel/smtpmailgidonly/conf.yaml' ) {
print "Loaded custom smtpmailgidonly/conf.yaml\n";
my $cfg = Cpanel::YAML::LoadFile('/var/cpanel/smtpmailgidonly/conf.yaml');
push @PORTS, grep { _valid_port($_) } @{ $cfg->{'PORTS'} } if ref $cfg->{'PORTS'} eq 'ARRAY';
push @RULES, grep { _valid_rule($_) } @{ $cfg->{'RULES'} } if ref $cfg->{'RULES'} eq 'ARRAY';
}
my $enabled = -e '/var/cpanel/smtpgidonlytweak';
if ( $action eq 'status' ) {
print "Protection is: " . ( $enabled ? 'on' : 'off' ) . "\n";
return 0;
}
if ( $action eq 'refresh' ) {
$action = ( $enabled ? 'on' : 'off' );
print "Refreshing SMTP Mail protection.\n";
}
my $removed_ok = remove_firewall_rules( $action =~ /^(?:start|stop)$/ );
if ( $action =~ /^(?:on|start)$/ ) {
if ( !add_firewall_rules( $action eq 'start' ) ) {
return 1;
}
print "SMTP Mail protection has been enabled.\n";
print "All outbound SMTP connections will be redirected to localhost except:\n";
foreach my $rule (@RULES) {
print "\t$rule->{'type'} is $rule->{'name'} (ports: " . join( ',', @PORTS ) . ")\n";
}
}
else {
if ( !$removed_ok ) {
print "There was a problem persisting the nftables removal. Protection may not remain disabled after a reboot.\n";
return 1;
}
print "SMTP Mail protection has been disabled. All users may make outbound smtp connections.\n";
}
return 0;
}
sub add_firewall_rules {
my ($start_only) = @_;
if ( _use_nftables() ) {
# Add the nft rules, then persist and reload.
# If any of that fails, remove any rules we may have added and bail out.
if ( !_add_nft_rules() || !_nft_persist_and_reload() ) {
remove_firewall_rules();
print "SMTP Mail protection has been disabled. All users may make smtp connections.\n";
print "There was a problem setting up nftables rules for SMTP restrictions.\n";
return 0;
}
}
else {
foreach my $type (@RULE_TYPES) {
foreach my $rule (@RULES) {
my $result = _iptables( ( $type->{'table'} ? ( '-t', $type->{'table'} ) : () ), $type->{'method'}, 'OUTPUT', '-p', 'tcp', ( ref $rule->{'args'} ? @{ $rule->{'args'} } : () ), '-m', 'multiport', '--dports', join( ',', @PORTS ), '-m', 'owner', '--' . $rule->{'type'} . '-owner', $rule->{'value'}, '-j', $type->{'target'} );
if ( $result =~ m/(?:No\s+chain|target\s+problem|Unknown\s+error|cannot\s+open\s+shared\s+object\s+file)/i ) {
remove_firewall_rules();
print "SMTP Mail protection has been disabled. All users may make smtp connections.\n";
print "There was a problem setting up iptables. You either have an older kernel or a broken iptables install, or ipt_owner could not be loaded.\n";
return 0;
}
}
}
_iptables( '-t', 'nat', '-A', 'OUTPUT', '-p', 'tcp', '-m', 'multiport', '--dports', join( ',', @PORTS ), '-j', 'REDIRECT' );
}
return 1 if $start_only;
my $cpconf = Cpanel::Config::CpConfGuard->new();
$cpconf->{data}->{smtpmailgidonly} = 1;
$cpconf->save();
Cpanel::FileUtils::TouchFile::touchfile('/var/cpanel/smtpgidonlytweak');
return 1;
}
sub remove_firewall_rules {
my ($stop_only) = @_;
debug("Removing old rules");
if ( !-e '/etc/csf' ) { #case 57565: removing these breaks outbound mail if csf has SMTP_BLOCK=1
# Old method needs to be removed
foreach my $rule (@RULES) {
_iptables( '-D', 'OUTPUT', '--protocol', 'tcp', ( ref $rule->{'args'} ? @{ $rule->{'args'} } : () ), '--dport', '25', '-m', 'owner', '--' . $rule->{'type'} . '-owner', $rule->{'value'}, '-j', 'ACCEPT' );
}
_iptables( '-D', 'OUTPUT', '--protocol', 'tcp', '-d', '127.0.0.1', '--dport', '25', '-j', 'ACCEPT' );
_iptables( '-D', 'OUTPUT', '--protocol', 'tcp', '--dport', '25', '-j', 'REJECT' );
}
debug("Removing new type rules");
{
# New Method
foreach my $type (@RULE_TYPES) {
foreach my $rule (@RULES) {
_iptables( ( $type->{'table'} ? ( '-t', $type->{'table'} ) : () ), '-D', 'OUTPUT', '-p', 'tcp', ( ref $rule->{'args'} ? @{ $rule->{'args'} } : () ), '-m', 'multiport', '--dports', join( ',', @PORTS ), '-m', 'owner', '--' . $rule->{'type'} . '-owner', $rule->{'value'}, '-j', $type->{'target'} );
}
}
_iptables( '-t', 'nat', '-D', 'OUTPUT', '-p', 'tcp', '-m', 'multiport', '--dports', join( ',', @PORTS ), '-j', 'REDIRECT' );
}
debug("Removing multiport rules matching 25...");
{
foreach my $type (@RULE_TYPES) {
# Remove any remaining port 25 rules
my %port_lists;
foreach my $line ( split( /\n/, _iptables( ( $type->{'table'} ? ( '-t', $type->{'table'} ) : () ), '-L', '-n' ) ) ) {
#RETURN tcp -- 0.0.0.0/0 127.0.0.1 multiport dports 25,26,122,125,232,434,465,587,809,5454 OWNER UID match 32001
if ( $line =~ m/multiport\s+dports\s+(25,[,0-9]+)\s+(?i:OWNER)\s+[UG]ID\s+match/ ) {
$port_lists{$1} = 1;
}
}
foreach my $port_list ( keys %port_lists ) {
foreach my $rule (@RULES) {
_iptables( ( $type->{'table'} ? ( '-t', $type->{'table'} ) : () ), '-D', 'OUTPUT', '-p', 'tcp', ( ref $rule->{'args'} ? @{ $rule->{'args'} } : () ), '-m', 'multiport', '--dports', $port_list, '-m', 'owner', '--' . $rule->{'type'} . '-owner', $rule->{'value'}, '-j', $type->{'target'} );
}
if ( $type->{'table'} && $type->{'table'} eq 'nat' ) {
_iptables( '-t', 'nat', '-D', 'OUTPUT', '-p', 'tcp', '-m', 'multiport', '--dports', $port_list, '-j', 'REDIRECT' );
}
}
}
}
# Remove native nftables SMTP restriction tables
my $persisted_ok = 1;
if ( _use_nftables() ) {
_remove_nft_tables();
$persisted_ok = _nft_persist_and_reload();
}
return $persisted_ok if $stop_only;
my $cpconf = Cpanel::Config::CpConfGuard->new();
$cpconf->{data}->{smtpmailgidonly} = 0;
$cpconf->save();
unlink '/var/cpanel/smtpgidonlytweak'; # For WHM
return $persisted_ok;
}
sub debug {
print "[$_[0]]\n" if $ENV{'CPANEL_DEBUG'};
return;
}
sub _use_nftables {
return Cpanel::OS::firewall_module() eq 'NFTables' ? 1 : 0;
}
sub _valid_port {
my ($port) = @_;
return defined $port && $port =~ /^[0-9]+\z/ && $port > 0 && $port < 65536;
}
sub _valid_rule {
my ($rule) = @_;
return 0 unless ref $rule eq 'HASH';
return 0 unless defined $rule->{'type'} && $rule->{'type'} =~ /^(?:uid|gid)\z/;
return 0 unless defined $rule->{'value'} && $rule->{'value'} =~ /^[0-9]+\z/;
return 0 unless defined $rule->{'name'} && $rule->{'name'} =~ /^[A-Za-z0-9_.-]+\z/;
return 0 if exists $rule->{'args'} && defined $rule->{'args'} && ref $rule->{'args'} ne 'ARRAY';
return 1;
}
sub _nft_cmd {
my (@args) = @_;
my $nft = Cpanel::Binaries::path('nft');
debug( "EXEC: " . join( ' ', $nft, @args ) );
my $run = Cpanel::SafeRun::Object->new(
program => $nft,
args => \@args,
);
my $stdout = $run->stdout() // '';
my $stderr = $run->stderr() // '';
my $err = $run->CHILD_ERROR();
my $ok = $err == 0 ? 1 : 0;
if ( !$ok ) {
# Deleting an already-absent table is benign and happens on the
# enable/refresh path before the ruleset exists. Don't alarm the user.
my $tolerable = join( ' ', @args ) =~ /^delete\s+table\b/ && $stderr =~ /No such file or directory/;
print STDERR "Error running $nft " . join( ' ', @args ) . " - Exit code: $err: $stderr\n" unless $tolerable;
}
debug("EXEC RESULT (ok=$ok): $stdout");
return ( $ok, $stdout );
}
sub _add_nft_rules {
my $table = NFT_TABLE;
# nft accepts comma-separated set literals on its CLI as individual
# tokens; building the set as an arg list keeps Perl from doing any
# shell-like splitting and lets us interpolate ports safely.
my @ports_set = ( '{', ( join ', ', @PORTS ), '}' );
for my $family (qw(ip ip6)) {
return 0 unless _nft_ok( 'add', 'table', $family, $table );
# nat hook on OUTPUT must use NF_IP_PRI_NAT_DST (-100);
# filter hook uses NF_IP_PRI_FILTER (0).
return 0 unless _nft_ok( 'add', 'chain', $family, $table, 'output_nat', qw({ type nat hook output priority -100 ; policy accept ; }) );
return 0 unless _nft_ok( 'add', 'chain', $family, $table, 'output_filter', qw({ type filter hook output priority 0 ; policy accept ; }) );
foreach my $rule (@RULES) {
my @owner_match = $rule->{'type'} eq 'uid' ? ( 'meta', 'skuid', $rule->{'value'} ) : ( 'meta', 'skgid', $rule->{'value'} );
my ( $daddr_ref, $skip_family ) = _nft_translate_rule_args( $rule->{'args'}, $family );
return 0 unless defined $daddr_ref;
next if $skip_family;
my @daddr = @$daddr_ref;
return 0 unless _nft_ok( 'add', 'rule', $family, $table, 'output_nat', 'tcp', 'dport', @ports_set, @daddr, @owner_match, 'return' );
return 0 unless _nft_ok( 'add', 'rule', $family, $table, 'output_filter', 'tcp', 'dport', @ports_set, @daddr, @owner_match, 'accept' );
}
return 0 unless _nft_ok( 'add', 'rule', $family, $table, 'output_nat', 'tcp', 'dport', @ports_set, 'redirect' );
}
return 1;
}
sub _nft_ok {
my ($ok) = _nft_cmd(@_);
return $ok;
}
# Translate the iptables-style $rule->{'args'} (e.g. [ '-d', '127.0.0.1' ])
# into the equivalent nftables match tokens for the given $family ('ip' or
# 'ip6').
#
# Returns ( \@nft_tokens, $skip_family ) on success:
# - \@nft_tokens is the list of nft match args to splice into the rule
# (empty list ref when $args is undef/empty)
# - $skip_family is true when the rule targets a different address family
# and should be skipped on this pass.
#
# Returns ( undef, undef ) on unsupported / invalid args. The caller should
# treat this as a hard failure and fall back to remove_firewall_rules().
sub _nft_translate_rule_args {
my ( $args, $family ) = @_;
return ( [], 0 ) unless ref $args eq 'ARRAY' && @$args;
my @tokens = @$args;
my @nft;
while (@tokens) {
my $opt = shift @tokens;
if ( $opt eq '-d' || $opt eq '--destination' ) {
my $dest = shift @tokens;
if ( !defined $dest || $dest eq '' ) {
print STDERR "smtpmailgidonly: rule arg '$opt' is missing its value; refusing to add nftables rule.\n";
return ( undef, undef );
}
my $is_v4 = Cpanel::Validate::IP::v4::is_valid_ipv4($dest) ? 1 : 0;
my $is_v6 = !$is_v4 && Cpanel::Validate::IP::is_valid_ipv6($dest) ? 1 : 0;
if ( !$is_v4 && !$is_v6 ) {
print STDERR "smtpmailgidonly: rule arg '$opt $dest' is not a valid IP address; refusing to add nftables rule.\n";
return ( undef, undef );
}
# Skip the rule on a family it doesn't belong to so that an
# IPv4-only destination doesn't generate a broken ip6 rule.
return ( [], 1 ) if ( $is_v4 && $family ne 'ip' ) || ( $is_v6 && $family ne 'ip6' );
push @nft, $family, 'daddr', $dest;
}
else {
print STDERR "smtpmailgidonly: rule arg '$opt' is not supported by the nftables backend; refusing to add nftables rule.\n";
return ( undef, undef );
}
}
return ( \@nft, 0 );
}
sub _remove_nft_tables {
my $table = NFT_TABLE;
for my $family (qw(ip ip6)) {
_nft_cmd( 'delete', 'table', $family, $table );
}
return;
}
sub _nft_persist_and_reload {
my ( $ok, $ruleset ) = _nft_cmd( 'list', 'ruleset' );
return 0 unless $ok;
chomp($ruleset);
# Persist only the table we own, not the full foreign-table-laden dump.
$ruleset = _filter_owned_tables( $ruleset, NFT_TABLE );
my $config_file = Cpanel::OS::nftables_config_file();
local $@;
# The rules are already live, so declining to touch the config warns instead
# of failing, which would roll them back and switch protection off.
my $skip_reason;
if ( -l $config_file ) {
$skip_reason = 'it is a symlink';
}
else {
eval {
my $content = _merge_persisted_tables( $config_file, $ruleset, map { "$_ " . NFT_TABLE } qw(ip ip6) );
if ( !defined $content ) {
$skip_reason = 'nft validation looked unavailable';
}
elsif ( !_validate_nft_config($content) ) {
$skip_reason = 'the resulting config would not reload';
}
else {
Cpanel::FileUtils::Write::overwrite( $config_file, $content );
}
1;
} or do {
print STDERR "smtpmailgidonly: failed to persist nftables ruleset to $config_file: $@";
return 0;
};
}
print STDERR "smtpmailgidonly: not persisting nftables rules to $config_file: $skip_reason\n" if $skip_reason;
# The nft rules were already added and are live; firewalld manages its
# own reload separately from nftables.service.
if ( Cpanel::Services::Firewall::is_firewalld() ) {
my $reload = Cpanel::SafeRun::Object->new( program => Cpanel::Binaries::path('firewall-cmd'), args => ['--reload'] );
return $reload->CHILD_ERROR() == 0 ? 1 : 0;
}
return 1;
}
# Net brace count on a line of nft output, ignoring braces inside a quoted
# string or after an unquoted "#" comment (both legal in nft's own output).
sub _brace_delta {
my ($line) = @_;
( my $stripped = $line ) =~ s/"[^"]*"//g;
$stripped =~ s/#.*//;
return ( $stripped =~ tr/\{// ) - ( $stripped =~ tr/\}// );
}
# True if $content (which may be empty) would load cleanly via `nft -f`.
sub _validate_nft_config {
my ($content) = @_;
return 1 unless length $content;
# Retry once: a single failure can be the validator itself hitting a
# transient hiccup rather than the content actually being broken.
# Genuinely invalid content fails the same way on every attempt.
for ( 1 .. 2 ) {
my $check = Cpanel::SafeRun::Object->new( program => Cpanel::Binaries::path('nft'), args => [ '-c', '-f', '-' ], stdin => $content );
return 1 if $check->CHILD_ERROR() == 0;
}
return 0;
}
# Drops block(s) matching @owned ('<family> <table>' pairs), carrying an
# owner flag, or that individually fail to validate (e.g. a stale xt-compat
# block), then appends $new_tables. Everything else is left untouched.
sub _merge_persisted_tables {
my ( $config_file, $new_tables, @owned ) = @_;
my %owned = map { $_ => 1 } @owned;
my $existing = Cpanel::LoadFile::load_nofollow_if_exists($config_file) // '';
my @lines = split /\n/, $existing;
my @kept;
my ( $foreign_seen, $foreign_dropped ) = ( 0, 0 );
my $i = 0;
while ( $i < @lines ) {
my $line = $lines[$i];
if ( $line =~ /^\s*table\s+(\S+)\s+(\S+)\s*\{/ ) {
my ( $family, $name ) = ( $1, $2 ); # capture before _brace_delta() can clobber $1/$2
my $start = $i;
my $depth = _brace_delta($line);
$i++;
while ( $depth > 0 && $i < @lines ) {
$depth += _brace_delta( $lines[$i] );
$i++;
}
my @block = @lines[ $start .. $i - 1 ];
if ( $owned{"$family $name"} || grep { /^\s*flags\s+\S*\bowner\b/ } @block ) {
next;
}
$foreign_seen++;
# Validate against the already-kept prefix, not the block alone:
# a table can legitimately depend on a top-level 'define' from
# earlier in the file, which isolated validation would not see.
if ( !_validate_nft_config( join( "\n", @kept, @block ) ) ) {
print STDERR "smtpmailgidonly: dropping table '$family $name' from $config_file: it would not reload\n";
$foreign_dropped++;
next;
}
push @kept, @block;
next;
}
push @kept, $line;
$i++;
}
# Every foreign table failing at once smells like the validator itself
# is unavailable, not that each table is individually broken; abort
# rather than risk silently persisting an emptied-out config.
if ( $foreign_seen && $foreign_dropped == $foreign_seen ) {
print STDERR "smtpmailgidonly: all foreign tables in $config_file failed validation at once; refusing to persist\n";
return undef;
}
my $content = join( "\n", @kept );
$content .= "\n" if length($content) && $content !~ /\n\z/;
$content .= "$new_tables\n" if length $new_tables;
return $content;
}
# From `nft list ruleset` text, keep only the top-level table block(s) named $table_name.
sub _filter_owned_tables {
my ( $ruleset, $table_name ) = @_;
my @kept;
my $depth = 0;
my $owned = 0;
for my $line ( split /\n/, $ruleset ) {
if ( $depth == 0 ) {
$owned = ( $line =~ /^\s*table\s+\S+\s+(\S+)\s*\{/ && $1 eq $table_name ) ? 1 : 0;
}
push @kept, $line if $owned;
$depth += _brace_delta($line);
}
return join( "\n", @kept );
}
sub _iptables {
my @rule_content = @_;
if ( -x '/sbin/ip6tables' ) {
my @rule6_content = @rule_content;
foreach my $part (@rule6_content) {
$part =~ s/127\.0\.0\.1/\:\:1\/128/g; # change local host to ipv6 equiv
}
debug( "EXEC: " . join( ' ', '/sbin/ip6tables', @rule6_content ) );
my $result6 = Cpanel::SafeRun::Errors::saferunallerrors( '/sbin/ip6tables', @rule6_content ) . "\n";
debug("EXEC RESULT: $result6");
}
debug( "EXEC: " . join( ' ', '/sbin/iptables', @rule_content ) );
my $result = Cpanel::SafeRun::Errors::saferunallerrors( '/sbin/iptables', @rule_content ) . "\n";
debug("EXEC RESULT: $result");
return $result;
}
exit( run(@ARGV) // 0 ) unless caller;
1;