Add profiles support

This commit is contained in:
Ilia Ross
2026-05-02 20:37:46 +02:00
parent a664b9d0c0
commit 272a8715f7
4 changed files with 441 additions and 179 deletions

View File

@@ -263,6 +263,7 @@ if (@tables) {
print ui_buttons_start();
print ui_buttons_row("apply.cgi", $text{'index_apply'}, $text{'index_applydesc'});
print ui_buttons_row("active.cgi", $text{'index_active'}, $text{'index_activedesc'});
print ui_buttons_row("setup.cgi", $text{'index_setup'}, $text{'index_setupdesc'});
print ui_buttons_end();
}

View File

@@ -69,18 +69,62 @@ save_err=Failed to save rule
apply_err=Failed to apply configuration
apply_enone=No saved nftables tables were found to apply.
apply_eexternal=Cannot apply configuration because table $1 is currently marked as externally managed.
setup_title=Setup Default Ruleset
setup_header=Create Default Ruleset
setup_desc=This page allows you to create a default nftables ruleset. Select one of the options below and click 'Create'.
setup_deny_note=Deny options will still allow SSH (port 22), Webmin (port $1), and localhost (loopback).
setup_allow_all=Allow all traffic
setup_deny_incoming=Deny all incoming traffic (except SSH and Webmin), allow all outgoing
setup_deny_all=Deny all traffic (except SSH and Webmin)
setup_title=Create Ruleset Profile
setup_header=Ruleset profile
setup_err=Failed to create ruleset profile
setup_table_name=Table name
setup_profile=Profile
setup_profile_allow_all=Allow all traffic
setup_profile_allow_all_desc=Open input, forward and output policy
setup_profile_management=Management only
setup_profile_management_desc=Allow SSH and this Webmin service, with outgoing traffic allowed
setup_profile_web=Web server
setup_profile_web_desc=Management access plus HTTP and HTTPS
setup_profile_mail=Mail server
setup_profile_mail_desc=Management access plus SMTP, submission, SMTPS, POP3, POP3S, IMAP and IMAPS
setup_profile_dns=DNS server
setup_profile_dns_desc=Management access plus DNS, DNS-over-TLS, DHCPv6 client and mDNS
setup_profile_virtualmin=Virtualmin hosting server
setup_profile_virtualmin_desc=Management, web, mail, DNS, FTP, Usermin and passive FTP ports
setup_profile_locked=Locked-down server
setup_profile_locked_desc=Drop input, forward and output traffic except management and replies
setup_profile_custom=Custom selected services
setup_profile_custom_desc=Use only the services and ports selected below
setup_services=Allowed services and ports
setup_service_col=Service or port
setup_type_col=Type
setup_port_col=Port
setup_proto_col=Protocol
setup_type_service=Service
setup_type_port=Port
setup_svc_ssh=SSH
setup_svc_webmin=Webmin
setup_svc_dhcpv6=DHCPv6 client
setup_svc_dns=DNS
setup_svc_dot=DNS-over-TLS
setup_svc_ftp=FTP
setup_svc_http=HTTP
setup_svc_https=HTTPS
setup_svc_imap=IMAP
setup_svc_imaps=IMAPS
setup_svc_mdns=mDNS
setup_svc_pop3=POP3
setup_svc_pop3s=POP3S
setup_svc_smtp=SMTP
setup_svc_submission=SMTP submission
setup_svc_smtps=SMTPS
setup_port_ftp_data=FTP data
setup_port_ssh_alt=SFTP
setup_port_webmin_range=Webmin RPC
setup_port_usermin=Usermin
setup_port_passive_ftp=FTP passive range
setup_create=Create
setup_invalid_type=Invalid ruleset type selected.
setup_failed=Failed to create default ruleset: <pre>$1</pre>
index_setup=Create Default Ruleset
index_setupdesc=Create a default set of rules, for example to allow all traffic.
setup_edup=Table $1 already exists in Webmin's saved nftables configuration.
setup_eservice=Invalid service selected: $1
setup_failed=Failed to create ruleset profile: <pre>$1</pre>
index_setup=Create Ruleset Profile
index_setupdesc=Create a managed nftables table from a predefined profile.
index_table_create=Create Table
index_table_createdesc=Add a new nftables table.
index_table_delete=Delete Table

View File

@@ -1389,4 +1389,19 @@ if (get_miniserv_config(\%miniserv) && $miniserv{'port'} =~ /^\d+$/) {
return 10000;
}
# get_usermin_port()
# Returns the configured Usermin port, or 20000 if unknown
sub get_usermin_port
{
my %miniserv;
if (foreign_installed("usermin")) {
foreign_require("usermin", "usermin-lib.pl");
usermin::get_usermin_miniserv_config(\%miniserv);
if ($miniserv{'port'} =~ /^\d+$/) {
return $miniserv{'port'};
}
}
return 20000;
}
1;

View File

@@ -1,197 +1,399 @@
#!/usr/bin/perl
# setup.cgi
# Create a default nftables ruleset
# Create a Webmin-managed nftables profile table
require './nftables-lib.pl'; ## no critic
use strict;
use warnings;
our (%in, %text);
ReadParse();
error_setup($text{'setup_err'});
if ($in{'action'} eq 'create') {
my $type = $in{'type'};
my @tables;
if ($type eq 'allow_all') {
@tables = create_allow_all_ruleset();
}
elsif ($type eq 'deny_incoming') {
@tables = create_deny_incoming_ruleset();
}
elsif ($type eq 'deny_all') {
@tables = create_deny_all_ruleset();
}
else {
error($text{'setup_invalid_type'});
}
my $profile = $in{'profile'} || 'virtualmin';
my $table_name = $in{'table_name'} || default_profile_table_name();
$table_name =~ /^\w[\w-]*$/ || error($text{'create_ename'});
my $error = save_configuration(@tables);
if ($error) {
error(text('setup_failed', $error));
}
$error = apply_restore();
if ($error) {
error(text('setup_failed', $error));
}
webmin_log("setup", "create", $type);
redirect("index.cgi");
}
my @tables = get_nftables_save();
foreach my $t (@tables) {
if ($t->{'family'} eq 'inet' && $t->{'name'} eq $table_name) {
error(text('setup_edup', nft_table_spec($t)));
}
}
my ($active, $active_err) = get_active_nftables_save();
if (!$active_err) {
foreach my $t (@$active) {
if ($t->{'family'} eq 'inet' && $t->{'name'} eq $table_name &&
table_is_externally_managed($t)) {
error(text('create_eexternal', nft_table_spec($t)));
}
}
}
my @allow = grep { $_ ne '' } split(/\0/, $in{'allow'} || '');
my $table = create_profile_ruleset($profile, $table_name, \@allow);
push(@tables, $table);
my $error = save_configuration(@tables);
if ($error) {
error(text('setup_failed', $error));
}
$error = apply_restore();
if ($error) {
error(text('setup_failed', $error));
}
webmin_log("setup", "create", $profile,
{ 'family' => 'inet', 'table' => $table_name });
redirect("index.cgi?table_family=inet&table_name=".urlize($table_name));
}
ui_print_header(undef, $text{'setup_title'}, "", "intro", 1, 1);
print "<h3>$text{'setup_header'}</h3>";
my $webmin_port = get_webmin_port();
print "<p>$text{'setup_desc'}</p>";
print "<p>",text('setup_deny_note', $webmin_port),"</p>";
print ui_form_start("setup.cgi");
print ui_hidden("action", "create");
my @type_opts = (
[ 'allow_all', $text{'setup_allow_all'} . "<br>" ],
[ 'deny_incoming', $text{'setup_deny_incoming'} . "<br>" ],
[ 'deny_all', $text{'setup_deny_all'} ],
);
print ui_radio("type", "allow_all", \@type_opts);
my @profiles = setup_profiles();
my $profile = $in{'profile'} || 'virtualmin';
my %profile_map = map { $_->{'id'} => $_ } @profiles;
$profile = 'virtualmin' if (!$profile_map{$profile});
my %checked = map { $_ => 1 } @{$profile_map{$profile}->{'services'} || [ ]};
my @profile_opts = map { [ $_->{'id'}, $_->{'name'} ] } @profiles;
print ui_table_start($text{'setup_header'}, "width=100%", 2);
print ui_table_row($text{'setup_table_name'},
ui_textbox("table_name", $in{'table_name'} || profile_table_name($profile), 24));
print ui_table_row($text{'setup_profile'},
ui_select("profile", $profile, \@profile_opts, 1, 0, 0, 0).
ui_tag('div', ui_note($profile_map{$profile}->{'desc'}, 0),
{ 'id' => 'nftables_profile_note',
'style' => 'margin-top: 0.35em; margin-left: 0.15em;' }));
print ui_table_end();
my @services = setup_services();
my @links = ( select_all_link("allow", 0),
select_invert_link("allow", 0) );
print ui_hr();
print ui_links_row(\@links);
my @tds = ( "width=5" );
print ui_columns_start(
[ "", $text{'setup_service_col'}, $text{'setup_type_col'},
$text{'setup_port_col'}, $text{'setup_proto_col'} ], 100, 0, \@tds,
$text{'setup_services'});
foreach my $svc (sort { lc($a->{'label'}) cmp lc($b->{'label'}) } @services) {
print ui_checked_columns_row([
$svc->{'label'},
$svc->{'type'},
$svc->{'port'},
$svc->{'proto'},
], \@tds, "allow", $svc->{'id'}, $checked{$svc->{'id'}});
}
print ui_columns_end();
print profile_javascript(@profiles);
print ui_form_end([ [ undef, $text{'setup_create'} ] ]);
ui_print_footer("index.cgi", $text{'index_return'});
sub create_allow_all_ruleset
sub setup_profiles
{
my @tables;
my $table = {
'name' => 'inet_filter',
'family' => 'inet',
'rules' => [],
'sets' => {},
'chains' => {
'input' => {
'type' => 'filter',
'hook' => 'input',
'priority' => 0,
'policy' => 'accept'
},
'forward' => {
'type' => 'filter',
'hook' => 'forward',
'priority' => 0,
'policy' => 'accept'
},
'output' => {
'type' => 'filter',
'hook' => 'output',
'priority' => 0,
'policy' => 'accept'
}
}
};
push(@tables, $table);
return @tables;
return (
{ 'id' => 'allow_all',
'name' => $text{'setup_profile_allow_all'},
'desc' => $text{'setup_profile_allow_all_desc'},
'input' => 'accept',
'forward' => 'accept',
'output' => 'accept',
'services' => [ ] },
{ 'id' => 'management',
'name' => $text{'setup_profile_management'},
'desc' => $text{'setup_profile_management_desc'},
'input' => 'drop',
'forward' => 'drop',
'output' => 'accept',
'services' => [ qw(ssh webmin) ] },
{ 'id' => 'web',
'name' => $text{'setup_profile_web'},
'desc' => $text{'setup_profile_web_desc'},
'input' => 'drop',
'forward' => 'drop',
'output' => 'accept',
'services' => [ qw(ssh webmin http https) ] },
{ 'id' => 'mail',
'name' => $text{'setup_profile_mail'},
'desc' => $text{'setup_profile_mail_desc'},
'input' => 'drop',
'forward' => 'drop',
'output' => 'accept',
'services' => [ qw(ssh webmin smtp submission smtps pop3 pop3s imap imaps) ] },
{ 'id' => 'dns',
'name' => $text{'setup_profile_dns'},
'desc' => $text{'setup_profile_dns_desc'},
'input' => 'drop',
'forward' => 'drop',
'output' => 'accept',
'services' => [ qw(ssh webmin dhcpv6 dns dot mdns) ] },
{ 'id' => 'virtualmin',
'name' => $text{'setup_profile_virtualmin'},
'desc' => $text{'setup_profile_virtualmin_desc'},
'input' => 'drop',
'forward' => 'drop',
'output' => 'accept',
'services' => [ qw(ssh webmin dhcpv6 dns dot ftp http https imap imaps
mdns pop3 pop3s smtp submission smtps ftp_data
ssh_alt webmin_range usermin passive_ftp) ] },
{ 'id' => 'locked',
'name' => $text{'setup_profile_locked'},
'desc' => $text{'setup_profile_locked_desc'},
'input' => 'drop',
'forward' => 'drop',
'output' => 'drop',
'services' => [ qw(ssh webmin) ] },
{ 'id' => 'custom',
'name' => $text{'setup_profile_custom'},
'desc' => $text{'setup_profile_custom_desc'},
'input' => 'drop',
'forward' => 'drop',
'output' => 'accept',
'services' => [ ] },
);
}
sub create_deny_incoming_ruleset
sub setup_services
{
my @tables;
my $webmin_port = get_webmin_port();
my $table = {
'name' => 'inet_filter',
'family' => 'inet',
'rules' => [
{
'text' => 'ct state established,related accept',
'chain' => 'input'
},
{
'text' => 'iif "lo" accept',
'chain' => 'input'
},
{
'text' => 'tcp dport 22 accept',
'chain' => 'input'
},
{
'text' => "tcp dport $webmin_port accept",
'chain' => 'input'
}
],
'sets' => {},
'chains' => {
'input' => {
'type' => 'filter',
'hook' => 'input',
'priority' => 0,
'policy' => 'drop'
},
'forward' => {
'type' => 'filter',
'hook' => 'forward',
'priority' => 0,
'policy' => 'accept'
},
'output' => {
'type' => 'filter',
'hook' => 'output',
'priority' => 0,
'policy' => 'accept'
}
}
};
push(@tables, $table);
return @tables;
my $webmin_port = get_webmin_port();
my $usermin_port = get_usermin_port();
return (
{ 'id' => 'ssh', 'label' => $text{'setup_svc_ssh'},
'type' => $text{'setup_type_service'}, 'port' => '22',
'proto' => 'TCP', 'rules' => [ 'tcp dport 22 accept' ] },
{ 'id' => 'webmin', 'label' => text('setup_svc_webmin', $webmin_port),
'type' => $text{'setup_type_service'}, 'port' => $webmin_port,
'proto' => 'TCP', 'rules' => [ "tcp dport $webmin_port accept" ] },
{ 'id' => 'dhcpv6', 'label' => $text{'setup_svc_dhcpv6'},
'type' => $text{'setup_type_service'}, 'port' => '546',
'proto' => 'UDP',
'rules' => [ 'ip6 daddr fe80::/64 udp dport 546 accept' ] },
{ 'id' => 'dns', 'label' => $text{'setup_svc_dns'},
'type' => $text{'setup_type_service'}, 'port' => '53',
'proto' => 'TCP/UDP',
'rules' => [ 'tcp dport 53 accept', 'udp dport 53 accept' ] },
{ 'id' => 'dot', 'label' => $text{'setup_svc_dot'},
'type' => $text{'setup_type_service'}, 'port' => '853',
'proto' => 'TCP', 'rules' => [ 'tcp dport 853 accept' ] },
{ 'id' => 'ftp', 'label' => $text{'setup_svc_ftp'},
'type' => $text{'setup_type_service'}, 'port' => '21',
'proto' => 'TCP', 'rules' => [ 'tcp dport 21 accept' ] },
{ 'id' => 'http', 'label' => $text{'setup_svc_http'},
'type' => $text{'setup_type_service'}, 'port' => '80',
'proto' => 'TCP', 'rules' => [ 'tcp dport 80 accept' ] },
{ 'id' => 'https', 'label' => $text{'setup_svc_https'},
'type' => $text{'setup_type_service'}, 'port' => '443',
'proto' => 'TCP', 'rules' => [ 'tcp dport 443 accept' ] },
{ 'id' => 'imap', 'label' => $text{'setup_svc_imap'},
'type' => $text{'setup_type_service'}, 'port' => '143',
'proto' => 'TCP', 'rules' => [ 'tcp dport 143 accept' ] },
{ 'id' => 'imaps', 'label' => $text{'setup_svc_imaps'},
'type' => $text{'setup_type_service'}, 'port' => '993',
'proto' => 'TCP', 'rules' => [ 'tcp dport 993 accept' ] },
{ 'id' => 'mdns', 'label' => $text{'setup_svc_mdns'},
'type' => $text{'setup_type_service'}, 'port' => '5353',
'proto' => 'UDP',
'rules' => [ 'ip daddr 224.0.0.251 udp dport 5353 accept',
'ip6 daddr ff02::fb udp dport 5353 accept' ] },
{ 'id' => 'pop3', 'label' => $text{'setup_svc_pop3'},
'type' => $text{'setup_type_service'}, 'port' => '110',
'proto' => 'TCP', 'rules' => [ 'tcp dport 110 accept' ] },
{ 'id' => 'pop3s', 'label' => $text{'setup_svc_pop3s'},
'type' => $text{'setup_type_service'}, 'port' => '995',
'proto' => 'TCP', 'rules' => [ 'tcp dport 995 accept' ] },
{ 'id' => 'smtp', 'label' => $text{'setup_svc_smtp'},
'type' => $text{'setup_type_service'}, 'port' => '25',
'proto' => 'TCP', 'rules' => [ 'tcp dport 25 accept' ] },
{ 'id' => 'submission', 'label' => $text{'setup_svc_submission'},
'type' => $text{'setup_type_service'}, 'port' => '587',
'proto' => 'TCP', 'rules' => [ 'tcp dport 587 accept' ] },
{ 'id' => 'smtps', 'label' => $text{'setup_svc_smtps'},
'type' => $text{'setup_type_service'}, 'port' => '465',
'proto' => 'TCP', 'rules' => [ 'tcp dport 465 accept' ] },
{ 'id' => 'ftp_data', 'label' => $text{'setup_port_ftp_data'},
'type' => $text{'setup_type_port'}, 'port' => '20',
'proto' => 'TCP', 'rules' => [ 'tcp dport 20 accept' ] },
{ 'id' => 'ssh_alt', 'label' => $text{'setup_port_ssh_alt'},
'type' => $text{'setup_type_port'}, 'port' => '2222',
'proto' => 'TCP', 'rules' => [ 'tcp dport 2222 accept' ] },
{ 'id' => 'webmin_range', 'label' => $text{'setup_port_webmin_range'},
'type' => $text{'setup_type_port'}, 'port' => '10000-10100',
'proto' => 'TCP', 'rules' => [ 'tcp dport 10000-10100 accept' ] },
{ 'id' => 'usermin', 'label' => $text{'setup_port_usermin'},
'type' => $text{'setup_type_port'}, 'port' => $usermin_port,
'proto' => 'TCP', 'rules' => [ "tcp dport $usermin_port accept" ] },
{ 'id' => 'passive_ftp', 'label' => $text{'setup_port_passive_ftp'},
'type' => $text{'setup_type_port'}, 'port' => '49152-65535',
'proto' => 'TCP', 'rules' => [ 'tcp dport 49152-65535 accept' ] },
);
}
sub create_deny_all_ruleset
sub create_profile_ruleset
{
my @tables;
my $webmin_port = get_webmin_port();
my $table = {
'name' => 'inet_filter',
'family' => 'inet',
'rules' => [],
'sets' => {},
'chains' => {
'input' => {
'type' => 'filter',
'hook' => 'input',
'priority' => 0,
'policy' => 'drop'
},
'forward' => {
'type' => 'filter',
'hook' => 'forward',
'priority' => 0,
'policy' => 'drop'
},
'output' => {
'type' => 'filter',
'hook' => 'output',
'priority' => 0,
'policy' => 'drop'
}
}
};
$table->{'rules'} = [
{
'text' => 'ct state established,related accept',
'chain' => 'output'
},
{
'text' => 'iif "lo" accept',
'chain' => 'input'
},
{
'text' => 'oif "lo" accept',
'chain' => 'output'
},
{
'text' => 'tcp dport 22 accept',
'chain' => 'input'
},
{
'text' => "tcp dport $webmin_port accept",
'chain' => 'input'
}
];
push(@tables, $table);
return @tables;
my ($profile_id, $table_name, $allow_ids) = @_;
my %profiles = map { $_->{'id'} => $_ } setup_profiles();
my $profile = $profiles{$profile_id} || error($text{'setup_invalid_type'});
my @services = setup_services();
my %services = map { $_->{'id'} => $_ } @services;
my %allow;
foreach my $id (@$allow_ids) {
$services{$id} || error(text('setup_eservice', $id));
$allow{$id} = 1;
}
my $table = {
'name' => $table_name,
'family' => 'inet',
'rules' => [ ],
'sets' => { },
'chains' => {
'input' => {
'type' => 'filter',
'hook' => 'input',
'priority' => 0,
'policy' => $profile->{'input'}
},
'forward' => {
'type' => 'filter',
'hook' => 'forward',
'priority' => 0,
'policy' => $profile->{'forward'}
},
'output' => {
'type' => 'filter',
'hook' => 'output',
'priority' => 0,
'policy' => $profile->{'output'}
}
}
};
return $table if ($profile_id eq 'allow_all');
add_profile_rule($table, 'input', 'ct state established,related accept');
add_profile_rule($table, 'input', 'iif "lo" accept');
add_profile_rule($table, 'input', 'meta l4proto { icmp, ipv6-icmp } accept');
if ($profile->{'output'} eq 'drop') {
add_profile_rule($table, 'output', 'ct state established,related accept');
add_profile_rule($table, 'output', 'oif "lo" accept');
add_profile_rule($table, 'output', 'meta l4proto { icmp, ipv6-icmp } accept');
}
my %seen;
foreach my $id (map { $_->{'id'} } @services) {
next if (!$allow{$id});
foreach my $rule (@{$services{$id}->{'rules'}}) {
next if ($seen{$rule}++);
add_profile_rule($table, 'input', $rule);
}
}
return $table;
}
ui_print_footer("/", $text{'index'});
sub profile_javascript
{
my (@profiles) = @_;
my %profile_services = map {
$_->{'id'} => $_->{'services'}
} @profiles;
my %profile_tables = map {
$_->{'id'} => profile_table_name($_->{'id'})
} @profiles;
my %profile_notes = map {
$_->{'id'} => ui_note($_->{'desc'}, 0)
} @profiles;
my $json = convert_to_json(\%profile_services);
my $table_json = convert_to_json(\%profile_tables);
my $note_json = convert_to_json(\%profile_notes);
return <<EOF;
<script type='text/javascript'>
(function() {
var profileServices = $json;
var profileTables = $table_json;
var profileNotes = $note_json;
var tableInput = document.querySelector('input[name="table_name"]');
var profileSelect = document.querySelector('select[name="profile"]');
var profileNote = document.getElementById('nftables_profile_note');
var tableNameTouched = false;
if (tableInput) {
tableInput.addEventListener('input', function() {
tableNameTouched = true;
});
}
function applyProfileServices(profile) {
var selected = {};
(profileServices[profile] || []).forEach(function(id) {
selected[id] = true;
});
document.querySelectorAll('input[name="allow"]').forEach(function(input) {
var checked = !!selected[input.value];
if (input.checked != checked) {
input.click();
}
});
}
function applyProfileTable(profile) {
if (!tableInput || tableNameTouched || !profileTables[profile]) {
return;
}
tableInput.value = profileTables[profile];
}
function applyProfileNote(profile) {
if (profileNote && profileNotes[profile]) {
profileNote.innerHTML = profileNotes[profile];
}
}
if (profileSelect) {
profileSelect.addEventListener('change', function() {
applyProfileServices(this.value);
applyProfileTable(this.value);
applyProfileNote(this.value);
});
}
})();
</script>
EOF
}
sub add_profile_rule
{
my ($table, $chain, $text) = @_;
push(@{$table->{'rules'}}, {
'text' => $text,
'chain' => $chain,
'index' => scalar(@{$table->{'rules'}}),
});
return;
}
sub profile_table_name
{
my ($profile) = @_;
my %names = (
'allow_all' => 'profile_allow_all',
'management' => 'profile_management',
'web' => 'profile_web',
'mail' => 'profile_mail',
'dns' => 'profile_dns',
'virtualmin' => 'profile_hosting',
'locked' => 'profile_locked',
'custom' => 'profile_custom',
);
my $base = $names{$profile} || 'profile_custom';
my @tables = get_nftables_save();
my %used = map { $_->{'family'} eq 'inet' ? ($_->{'name'} => 1) : ( ) }
@tables;
my $name = $base;
my $i = 1;
while ($used{$name}) {
$name = $base."_".$i++;
}
return $name;
}
sub default_profile_table_name
{
return profile_table_name('virtualmin');
}