1862 lines
62 KiB
Perl
1862 lines
62 KiB
Perl
#!/usr/bin/env perl
|
|||
|
|
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||
|
|
# SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
# Idempotent server setup for Fedora Server, CentOS Stream and openEuler.
|
||
|
|
#
|
||
|
|
# Covers the system update, base packages, the EPEL repository on CentOS, the
|
||
|
|
# firewall, SELinux, Podman and automatic updates. Every operation checks the
|
||
|
|
# current state before acting, so running it again produces the same result with no
|
||
|
|
# errors and no repeated work.
|
||
|
|
#
|
||
|
|
# Perl builtins only: no module has to be installed. Two pieces of work Perl does
|
||
|
|
# not carry as builtins are therefore written out here:
|
||
|
|
#
|
||
|
|
# * the command runner, which forks and keeps stdout and stderr apart in the
|
||
|
|
# scratch directory, because the messages quote stderr while the parsers read
|
||
|
|
# stdout;
|
||
|
|
# * the atomic file replacement, which is rename(2) over a temporary file. The
|
||
|
|
# durability barrier goes through sync(1), because builtins expose no fsync;
|
||
|
|
# the atomicity comes from the rename either way, so a crash mid-write cannot
|
||
|
|
# leave a boot-critical config truncated.
|
||
|
|
#
|
||
|
|
# External binaries used: dnf, rpm, systemctl, usermod, getenforce, setenforce,
|
||
|
|
# firewall-offline-cmd, firewall-cmd, podman and sync.
|
||
|
|
#
|
||
|
|
# The firewall is configured through the offline client before the service is
|
||
|
|
# enabled, and SSH is verified in the permanent configuration first, so a
|
||
|
|
# misconfiguration can never lock a remote administrator out.
|
||
|
|
#
|
||
|
|
# Usage:
|
||
|
|
# server-setup.pl # full setup
|
||
|
|
# server-setup.pl --dry-run # preview without changes
|
||
|
|
# server-setup.pl --skip-update # skip system update
|
||
|
|
# server-setup.pl --skip-packages # skip package installation
|
||
|
|
# server-setup.pl --skip-epel # skip EPEL setup on CentOS
|
||
|
|
# server-setup.pl --skip-firewall # skip firewall setup
|
||
|
|
# server-setup.pl --skip-selinux # skip SELinux
|
||
|
|
# server-setup.pl --skip-podman # skip Podman
|
||
|
|
# server-setup.pl --skip-auto-updates # skip automatic updates
|
||
|
|
# server-setup.pl --version
|
||
|
|
#
|
||
|
|
# Exit status is 0 when every enabled section succeeds, 1 otherwise.
|
||
|
|
|
||
|
|
use strict;
|
||
|
|
use warnings;
|
||
|
|
|
||
|
|
my $VERSION = '2.0.0';
|
||
|
|
|
||
|
|
my $BOLD = "\033[1m";
|
||
|
|
my $RED = "\033[31m";
|
||
|
|
my $GREEN = "\033[32m";
|
||
|
|
my $YELLOW = "\033[33m";
|
||
|
|
my $DIM = "\033[2m";
|
||
|
|
my $RESET = "\033[0m";
|
||
|
|
|
||
|
|
# Timeout in seconds for dnf operations: metadata downloads and package
|
||
|
|
# transactions on a fresh or slow system routinely exceed the 60 s used for cheap
|
||
|
|
# probes.
|
||
|
|
my $DNF_TIMEOUT = 1800;
|
||
|
|
|
||
|
|
# Supported systems, in the order the messages list them.
|
||
|
|
my @SUPPORTED_ORDER = ('fedora', 'centos', 'openeuler');
|
||
|
|
my %SUPPORTED_OS = (
|
||
|
|
fedora => 'Fedora',
|
||
|
|
centos => 'CentOS Stream',
|
||
|
|
openeuler => 'openEuler',
|
||
|
|
);
|
||
|
|
|
||
|
|
# Base packages installed on Fedora, CentOS Stream and openEuler alike.
|
||
|
|
my @BASE_PACKAGES = qw(
|
||
|
|
nano curl wget htop tmux rsync nginx openssl jq fastfetch
|
||
|
|
);
|
||
|
|
|
||
|
|
# Services to open in firewalld by default. ssh is mandatory: losing it means
|
||
|
|
# locking out remote administration.
|
||
|
|
my @FIREWALL_SERVICES = ('ssh');
|
||
|
|
|
||
|
|
# Opened in firewalld only when nginx is installed, which it is by default.
|
||
|
|
my @NGINX_FIREWALL_SERVICES = ('http', 'https');
|
||
|
|
|
||
|
|
# dnf-automatic configuration file. dnf 4 ships it with defaults; dnf 5 reads host
|
||
|
|
# overrides from this path (its own defaults live in /usr/share/dnf5).
|
||
|
|
my $DNF_AUTOMATIC_CONF = '/etc/dnf/automatic.conf';
|
||
|
|
|
||
|
|
# Timer units to probe, in order of preference: dnf 4 ships the -install variant,
|
||
|
|
# dnf 5 (Fedora 41 and newer) only the single dnf5-automatic.timer.
|
||
|
|
my @AUTO_UPDATE_TIMER_CANDIDATES = (
|
||
|
|
'dnf-automatic-install.timer',
|
||
|
|
'dnf5-automatic.timer',
|
||
|
|
'dnf-automatic.timer',
|
||
|
|
);
|
||
|
|
|
||
|
|
# The optional clock, loaded once and guarded where it is used.
|
||
|
|
my $HAVE_HIRES = eval { require Time::HiRes; 1 } ? 1 : 0;
|
||
|
|
|
||
|
|
my $TMP_DIR; # private scratch directory, created only when needed
|
||
|
|
my $PARENT_PID = $$; # a forked child must never clean up for the parent
|
||
|
|
my $RUN_SEQ = 0; # per-call suffix for the runner's files
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Progress helpers, all on stderr so stdout stays clean
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub _status {
|
||
|
|
my ($msg) = @_;
|
||
|
|
print STDERR " $msg...";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub _status_done {
|
||
|
|
my ($msg) = @_;
|
||
|
|
$msg = 'done' unless defined $msg;
|
||
|
|
print STDERR " $msg\n";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub _info {
|
||
|
|
my ($msg) = @_;
|
||
|
|
print STDERR " ${DIM}$msg$RESET\n";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub _warn {
|
||
|
|
my ($msg) = @_;
|
||
|
|
print STDERR " ${YELLOW}⚠ $msg$RESET\n";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub _ok {
|
||
|
|
my ($msg) = @_;
|
||
|
|
print STDERR " ${GREEN}✓ $msg$RESET\n";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub _fail {
|
||
|
|
my ($msg) = @_;
|
||
|
|
print STDERR " ${RED}✗ $msg$RESET\n";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# The clock, path lookup, scratch files and the command runner
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub now {
|
||
|
|
return $HAVE_HIRES ? Time::HiRes::time() : time();
|
||
|
|
}
|
||
|
|
|
||
|
|
# A hand-rolled which(1), so that the lookup itself needs no external binary.
|
||
|
|
sub find_exe {
|
||
|
|
my ($name) = @_;
|
||
|
|
return undef unless defined $name && length $name;
|
||
|
|
if (index($name, '/') >= 0) {
|
||
|
|
return (-f $name && -x _) ? $name : undef;
|
||
|
|
}
|
||
|
|
for my $dir (split /:/, ($ENV{PATH} // '')) {
|
||
|
|
next unless length $dir;
|
||
|
|
my $path = "$dir/$name";
|
||
|
|
return $path if -f $path && -x _;
|
||
|
|
}
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The same walk, but accepting a file that exists without being executable, so a
|
||
|
|
# permission problem can be told from a missing binary.
|
||
|
|
sub find_existing {
|
||
|
|
my ($name) = @_;
|
||
|
|
return undef unless defined $name && length $name;
|
||
|
|
if (index($name, '/') >= 0) {
|
||
|
|
return -f $name ? $name : undef;
|
||
|
|
}
|
||
|
|
for my $dir (split /:/, ($ENV{PATH} // '')) {
|
||
|
|
next unless length $dir;
|
||
|
|
my $path = "$dir/$name";
|
||
|
|
return $path if -f $path;
|
||
|
|
}
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
# mkdir is atomic and refuses to follow a symlink, so a hostile entry in a shared
|
||
|
|
# /tmp cannot redirect where the runner writes.
|
||
|
|
sub scratch_dir {
|
||
|
|
return $TMP_DIR if defined $TMP_DIR;
|
||
|
|
my $base = $ENV{TMPDIR} // '/tmp';
|
||
|
|
for my $attempt (0 .. 9) {
|
||
|
|
my $dir = "$base/server-setup.$$" . ($attempt ? ".$attempt" : '');
|
||
|
|
if (mkdir($dir, 0700)) {
|
||
|
|
$TMP_DIR = $dir;
|
||
|
|
return $dir;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
die "cannot create a scratch directory under $base\n";
|
||
|
|
}
|
||
|
|
|
||
|
|
sub remove_scratch {
|
||
|
|
return unless defined $TMP_DIR;
|
||
|
|
# The runner forks, and a forked child inherits the END block: only the process
|
||
|
|
# that created the directory may remove it.
|
||
|
|
return unless $$ == $PARENT_PID;
|
||
|
|
if (opendir(my $dh, $TMP_DIR)) {
|
||
|
|
for my $entry (readdir($dh)) {
|
||
|
|
next if $entry eq '.' || $entry eq '..';
|
||
|
|
unlink("$TMP_DIR/$entry");
|
||
|
|
}
|
||
|
|
closedir($dh);
|
||
|
|
}
|
||
|
|
rmdir($TMP_DIR);
|
||
|
|
undef $TMP_DIR;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub slurp {
|
||
|
|
my ($path) = @_;
|
||
|
|
open(my $fh, '<', $path) or return '';
|
||
|
|
my $text = do { local $/ = undef; <$fh> };
|
||
|
|
close($fh);
|
||
|
|
return defined $text ? $text : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
# The wait status packs the exit code into bits 8-15 and the killing signal into
|
||
|
|
# bits 0-6. A child that died from a signal (the OOM killer sends SIGKILL)
|
||
|
|
# leaves the exit code at 0, so the signal has to be folded in explicitly or a
|
||
|
|
# killed command would be mistaken for a successful one.
|
||
|
|
sub wait_status_rc {
|
||
|
|
my ($status) = @_;
|
||
|
|
my $signal = $status & 127;
|
||
|
|
return $signal ? 128 + $signal : $status >> 8;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Run a command and return { rc, out, err }.
|
||
|
|
#
|
||
|
|
# The locale is forced to C so that tool output is parsed in English. A missing
|
||
|
|
# binary, an unexecutable one and an expired timeout are reported and returned as
|
||
|
|
# rc 127, 126 and 124 instead of raising, so a caller always has a result to
|
||
|
|
# inspect. Nothing else is swallowed: the two streams are kept apart because the
|
||
|
|
# messages quote stderr while the parsers read stdout.
|
||
|
|
sub run {
|
||
|
|
my ($cmd, $timeout) = @_;
|
||
|
|
$timeout = 60 unless defined $timeout;
|
||
|
|
|
||
|
|
my $exe = find_exe($cmd->[0]);
|
||
|
|
if (!defined $exe) {
|
||
|
|
if (defined find_existing($cmd->[0])) {
|
||
|
|
_fail("Command failed: $cmd->[0]: not executable");
|
||
|
|
return { rc => 126, out => '', err => "$cmd->[0] is not executable" };
|
||
|
|
}
|
||
|
|
_fail("Command not found: $cmd->[0]");
|
||
|
|
return { rc => 127, out => '', err => "command not found: $cmd->[0]" };
|
||
|
|
}
|
||
|
|
|
||
|
|
my $dir = scratch_dir();
|
||
|
|
$RUN_SEQ++;
|
||
|
|
my $out_file = "$dir/out.$$.$RUN_SEQ";
|
||
|
|
my $err_file = "$dir/err.$$.$RUN_SEQ";
|
||
|
|
|
||
|
|
my $pid = fork();
|
||
|
|
die "cannot fork: $!\n" unless defined $pid;
|
||
|
|
if ($pid == 0) {
|
||
|
|
if (open(STDOUT, '>', $out_file) && open(STDERR, '>', $err_file)) {
|
||
|
|
$ENV{LANG} = 'C';
|
||
|
|
$ENV{LC_ALL} = 'C';
|
||
|
|
exec { $exe } @$cmd;
|
||
|
|
}
|
||
|
|
exit 126; # reached only when the redirection or the exec failed
|
||
|
|
}
|
||
|
|
|
||
|
|
my $timed_out = 0;
|
||
|
|
eval {
|
||
|
|
local $SIG{ALRM} = sub { die "alarm\n" };
|
||
|
|
alarm($timeout);
|
||
|
|
waitpid($pid, 0);
|
||
|
|
alarm(0);
|
||
|
|
1;
|
||
|
|
} or do { $timed_out = 1; alarm(0) };
|
||
|
|
|
||
|
|
my $rc;
|
||
|
|
if ($timed_out) {
|
||
|
|
kill('TERM', $pid);
|
||
|
|
select(undef, undef, undef, 0.1);
|
||
|
|
kill('KILL', $pid);
|
||
|
|
waitpid($pid, 0);
|
||
|
|
$rc = 124;
|
||
|
|
_fail("Command timed out after ${timeout}s: " . join(' ', @$cmd));
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
$rc = wait_status_rc($?);
|
||
|
|
}
|
||
|
|
|
||
|
|
my $out = slurp($out_file);
|
||
|
|
my $err = slurp($err_file);
|
||
|
|
unlink($out_file, $err_file);
|
||
|
|
return {
|
||
|
|
rc => $rc,
|
||
|
|
out => $out,
|
||
|
|
err => $timed_out ? "timed out after ${timeout}s" : $err,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Files written atomically
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Durability barrier for a file that has been written and closed. Builtins expose
|
||
|
|
# no fsync, so sync(1) is the transport; where it is absent the write is still
|
||
|
|
# atomic, only not durable against a power loss in the instant after the rename.
|
||
|
|
sub fsync_path {
|
||
|
|
my ($path) = @_;
|
||
|
|
my $sync = find_exe('sync');
|
||
|
|
return unless defined $sync;
|
||
|
|
# The rc is deliberately not inspected: this is a barrier, not a check.
|
||
|
|
system { $sync } $sync, $path;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The errno, the reason and the path, so a failure message names all three rather
|
||
|
|
# than only the reason. $! must
|
||
|
|
# be read straight after the failed operation, before anything else can change it.
|
||
|
|
sub os_error_text {
|
||
|
|
my ($path) = @_;
|
||
|
|
return "[Errno " . (0 + $!) . "] $!: '$path'";
|
||
|
|
}
|
||
|
|
|
||
|
|
# Replace a file with new content, preserving its mode and owner.
|
||
|
|
#
|
||
|
|
# Boot-critical configuration, meaning SELinux, dnf-automatic and unit files, must
|
||
|
|
# never be left truncated by a crash mid-write: the content goes into a temporary
|
||
|
|
# file beside the target, which is then renamed over it. The rename is the atomic
|
||
|
|
# step, and it either happened or it did not.
|
||
|
|
sub atomic_write {
|
||
|
|
my ($path, $content) = @_;
|
||
|
|
my ($mode, $uid, $gid) = (0644, 0, 0);
|
||
|
|
my @st = stat($path);
|
||
|
|
if (@st) {
|
||
|
|
$mode = $st[2] & 07777;
|
||
|
|
$uid = $st[4];
|
||
|
|
$gid = $st[5];
|
||
|
|
}
|
||
|
|
my $tmp = "$path.$$.tmp";
|
||
|
|
my $ok = eval {
|
||
|
|
open(my $fh, '>', $tmp) or die os_error_text($tmp) . "\n";
|
||
|
|
print {$fh} $content or die os_error_text($tmp) . "\n";
|
||
|
|
close($fh) or die os_error_text($tmp) . "\n";
|
||
|
|
fsync_path($tmp);
|
||
|
|
chmod($mode, $tmp) or die os_error_text($tmp) . "\n";
|
||
|
|
chown($uid, $gid, $tmp) or die os_error_text($tmp) . "\n";
|
||
|
|
rename($tmp, $path) or die "[Errno " . (0 + $!) . "] $!: '$tmp' -> '$path'\n";
|
||
|
|
# The replacement itself has to reach the disk too: the file content was
|
||
|
|
# synced above, but the directory entry that rename(2) rewrote needs the
|
||
|
|
# containing directory synced, or a power loss in the instant after
|
||
|
|
# could still revert the replacement.
|
||
|
|
(my $parent = $path) =~ s{/[^/]+$}{};
|
||
|
|
$parent = '/' unless length $parent;
|
||
|
|
fsync_path($parent);
|
||
|
|
1;
|
||
|
|
};
|
||
|
|
if (!$ok) {
|
||
|
|
my $error = $@ || 'unknown error';
|
||
|
|
unlink($tmp);
|
||
|
|
die $error;
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# OS detection and the root check
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub parse_os_release {
|
||
|
|
my %release;
|
||
|
|
open(my $fh, '<', '/etc/os-release') or return \%release;
|
||
|
|
while (my $line = <$fh>) {
|
||
|
|
$line =~ s/^\s+//;
|
||
|
|
$line =~ s/\s+$//;
|
||
|
|
next unless length $line;
|
||
|
|
next if index($line, '#') == 0;
|
||
|
|
next unless index($line, '=') >= 0;
|
||
|
|
my ($key, $value) = split /=/, $line, 2;
|
||
|
|
$value = '' unless defined $value;
|
||
|
|
for my $quote ('"', "'") {
|
||
|
|
$value =~ s/^\Q$quote\E+//;
|
||
|
|
$value =~ s/\Q$quote\E+$//;
|
||
|
|
}
|
||
|
|
$release{$key} = $value;
|
||
|
|
}
|
||
|
|
close($fh);
|
||
|
|
return \%release;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub detect_os {
|
||
|
|
my $release = parse_os_release();
|
||
|
|
my $os_id = lc($release->{ID} // '');
|
||
|
|
my $version_id = $release->{VERSION_ID} // 'unknown';
|
||
|
|
|
||
|
|
if (!exists $SUPPORTED_OS{$os_id}) {
|
||
|
|
print STDERR "${RED}${BOLD}Error:$RESET Unsupported operating system: "
|
||
|
|
. "'" . ($release->{ID} // 'unknown') . "' (detected from /etc/os-release).\n";
|
||
|
|
print STDERR " Supported systems: "
|
||
|
|
. join(', ', map { $SUPPORTED_OS{$_} } @SUPPORTED_ORDER) . "\n";
|
||
|
|
exit 1;
|
||
|
|
}
|
||
|
|
return ($os_id, $SUPPORTED_OS{$os_id}, $version_id);
|
||
|
|
}
|
||
|
|
|
||
|
|
sub check_root {
|
||
|
|
return if $> == 0;
|
||
|
|
print STDERR "${RED}${BOLD}Error:$RESET This script must be run as root (UID 0).\n";
|
||
|
|
print STDERR " Current UID: $>. Try: sudo perl server-setup.pl\n";
|
||
|
|
exit 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Section helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub rpm_installed {
|
||
|
|
my ($pkg) = @_;
|
||
|
|
return run(['rpm', '-q', $pkg])->{rc} == 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub dnf_install {
|
||
|
|
my ($packages) = @_;
|
||
|
|
return 1 unless @$packages;
|
||
|
|
return run(['dnf', 'install', '-y', @$packages], $DNF_TIMEOUT)->{rc} == 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 1. System update
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub system_update {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (
|
||
|
|
updated => 0,
|
||
|
|
skipped => 0,
|
||
|
|
failed => 0,
|
||
|
|
reboot_required => 0,
|
||
|
|
);
|
||
|
|
|
||
|
|
_status('Checking for system updates');
|
||
|
|
my $check = run(['dnf', 'check-update'], $DNF_TIMEOUT);
|
||
|
|
# dnf check-update: 0 means no updates, 100 means updates are available, and 1
|
||
|
|
# is an error.
|
||
|
|
if ($check->{rc} == 0) {
|
||
|
|
_status_done('already up to date');
|
||
|
|
$info{skipped} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
if ($check->{rc} != 100) {
|
||
|
|
_status_done('check failed');
|
||
|
|
_fail('dnf check-update failed, cannot determine update state');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status_done('updates available');
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would run: dnf update -y');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Applying system updates');
|
||
|
|
my $update = run(['dnf', 'update', '-y'], $DNF_TIMEOUT);
|
||
|
|
if ($update->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('System update returned non-zero exit code');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done();
|
||
|
|
$info{updated} = 1;
|
||
|
|
|
||
|
|
# dnf needs-restarting -r: rc 1 together with the message means a reboot is
|
||
|
|
# required. The plugin (dnf-utils) may be absent, and anything unrecognised is
|
||
|
|
# treated as unknown rather than as a false positive.
|
||
|
|
my $reboot_check = run(['dnf', 'needs-restarting', '-r']);
|
||
|
|
if ($reboot_check->{rc} == 1 && index($reboot_check->{out}, 'Reboot is required') >= 0) {
|
||
|
|
$info{reboot_required} = 1;
|
||
|
|
_warn('Reboot required to fully apply updates');
|
||
|
|
}
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub ensure_epel {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (
|
||
|
|
installed => 0,
|
||
|
|
already_enabled => 0,
|
||
|
|
failed => 0,
|
||
|
|
);
|
||
|
|
|
||
|
|
_status('Checking EPEL repository');
|
||
|
|
|
||
|
|
# Quick check: is epel-release installed and its repository enabled?
|
||
|
|
my $repo_check = run(['dnf', 'repolist', '--enabled'], 60);
|
||
|
|
if (index(lc($repo_check->{out}), 'epel') >= 0) {
|
||
|
|
_status_done('already enabled');
|
||
|
|
$info{already_enabled} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
my $epel_installed = rpm_installed('epel-release');
|
||
|
|
_status_done($epel_installed ? 'package installed but repo disabled' : 'not installed');
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would enable CRB repository');
|
||
|
|
_info($epel_installed ? 'Would enable EPEL repository' : 'Would install: epel-release');
|
||
|
|
$info{installed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Enable CodeReady Linux Builder (CRB), because some EPEL packages depend on it.
|
||
|
|
# The repository ships with CentOS but is disabled by default. Non-fatal when
|
||
|
|
# it is already enabled or unavailable here.
|
||
|
|
_status('Enabling CRB repository');
|
||
|
|
my $crb = run(['dnf', 'config-manager', '--set-enabled', 'crb'], 60);
|
||
|
|
if ($crb->{rc} == 0) {
|
||
|
|
_status_done();
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('not available (non-fatal)');
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($epel_installed) {
|
||
|
|
# The package is present while its repository is switched off: installing
|
||
|
|
# it again would be a successful no-op, so the repository itself is
|
||
|
|
# enabled instead.
|
||
|
|
_status('Enabling EPEL repository');
|
||
|
|
if (run(['dnf', 'config-manager', '--set-enabled', 'epel'], 60)->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to enable the EPEL repository');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done();
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status('Installing epel-release');
|
||
|
|
if (!dnf_install(['epel-release'])) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to install epel-release');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done();
|
||
|
|
}
|
||
|
|
|
||
|
|
# The verdict comes from the enabled repositories themselves, so a silent
|
||
|
|
# no-op cannot be reported as success.
|
||
|
|
my $verify = run(['dnf', 'repolist', '--enabled'], 60);
|
||
|
|
if (index(lc($verify->{out}), 'epel') < 0) {
|
||
|
|
_fail('EPEL repository is still not enabled');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_ok('EPEL repository enabled');
|
||
|
|
$info{installed} = 1;
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2. Base packages
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub install_packages {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my (@installed, @skipped, @failed, @to_install);
|
||
|
|
|
||
|
|
_status('Checking base packages');
|
||
|
|
for my $pkg (@BASE_PACKAGES) {
|
||
|
|
if (rpm_installed($pkg)) { push @skipped, $pkg }
|
||
|
|
else { push @to_install, $pkg }
|
||
|
|
}
|
||
|
|
|
||
|
|
my $already = scalar @skipped;
|
||
|
|
my $missing = scalar @to_install;
|
||
|
|
my $total = scalar @BASE_PACKAGES;
|
||
|
|
_status_done("$already/$total already installed");
|
||
|
|
|
||
|
|
if (!@to_install) {
|
||
|
|
_ok('All base packages already present');
|
||
|
|
return { installed => \@installed, skipped => \@skipped, failed => \@failed };
|
||
|
|
}
|
||
|
|
|
||
|
|
_info('Installing ' . $missing . ' package(s): ' . join(' ', @to_install));
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
for my $pkg (@to_install) {
|
||
|
|
_info(" Would install: $pkg");
|
||
|
|
push @installed, $pkg;
|
||
|
|
}
|
||
|
|
return { installed => \@installed, skipped => \@skipped, failed => \@failed };
|
||
|
|
}
|
||
|
|
|
||
|
|
# One transaction first, which is faster and atomic. When it fails, for
|
||
|
|
# instance because one package is unavailable on this distribution, the rest
|
||
|
|
# are installed one by one so that they still succeed.
|
||
|
|
_status("Installing $missing package(s) in one transaction");
|
||
|
|
if (dnf_install(\@to_install)) {
|
||
|
|
_status_done();
|
||
|
|
push @installed, @to_install;
|
||
|
|
return { installed => \@installed, skipped => \@skipped, failed => \@failed };
|
||
|
|
}
|
||
|
|
|
||
|
|
_status_done('transaction failed, falling back to per-package');
|
||
|
|
for my $pkg (@to_install) {
|
||
|
|
_status("Installing $pkg");
|
||
|
|
if (dnf_install([$pkg])) {
|
||
|
|
_status_done();
|
||
|
|
push @installed, $pkg;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to install $pkg");
|
||
|
|
push @failed, $pkg;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { installed => \@installed, skipped => \@skipped, failed => \@failed };
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 3. Firewall
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub setup_firewall {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (
|
||
|
|
installed => 0,
|
||
|
|
enabled => 0,
|
||
|
|
zone_set => 0,
|
||
|
|
services_added => [],
|
||
|
|
services_skipped => [],
|
||
|
|
failed => 0,
|
||
|
|
);
|
||
|
|
|
||
|
|
# --- Install firewalld when it is missing ---
|
||
|
|
_status('Checking firewalld');
|
||
|
|
my $firewalld_present = rpm_installed('firewalld');
|
||
|
|
if (!$firewalld_present) {
|
||
|
|
_status_done('not installed');
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would install: firewalld');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
if (dnf_install(['firewalld'])) {
|
||
|
|
_ok('Installed firewalld');
|
||
|
|
$info{installed} = 1;
|
||
|
|
$firewalld_present = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail('Failed to install firewalld');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('installed');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ssh is mandatory; http and https only when nginx is present.
|
||
|
|
my @services = @FIREWALL_SERVICES;
|
||
|
|
push @services, @NGINX_FIREWALL_SERVICES if rpm_installed('nginx');
|
||
|
|
|
||
|
|
# --- Permanent rules first, through the offline client, which needs no daemon
|
||
|
|
my $permanent_changed = 0;
|
||
|
|
for my $service (@services) {
|
||
|
|
_status("Checking firewall service '$service'");
|
||
|
|
if (!$firewalld_present) {
|
||
|
|
# A dry run on a host without firewalld has nothing to query yet.
|
||
|
|
_status_done('would add');
|
||
|
|
push @{ $info{services_added} }, $service;
|
||
|
|
$permanent_changed = 1;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
my $query = run(['firewall-offline-cmd', '--zone=public', "--query-service=$service"]);
|
||
|
|
if ($query->{rc} == 0) {
|
||
|
|
_status_done('already present');
|
||
|
|
push @{ $info{services_skipped} }, $service;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would add');
|
||
|
|
push @{ $info{services_added} }, $service;
|
||
|
|
$permanent_changed = 1;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
my $add = run(['firewall-offline-cmd', '--zone=public', "--add-service=$service"]);
|
||
|
|
if ($add->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to add service '$service' to zone 'public'");
|
||
|
|
$info{failed} = 1;
|
||
|
|
if ($service eq 'ssh') {
|
||
|
|
_warn('Aborting before firewalld is enabled to prevent lockout');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
_status_done('added');
|
||
|
|
push @{ $info{services_added} }, $service;
|
||
|
|
$permanent_changed = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Default zone before the service starts: this is the zone the daemon
|
||
|
|
# filters with the moment it comes up. It is set through the offline client,
|
||
|
|
# while firewalld is still stopped, so the lockout gate below verifies the state
|
||
|
|
# that will actually apply to traffic.
|
||
|
|
_status('Checking default zone');
|
||
|
|
my $zone_result = run(['firewall-offline-cmd', '--get-default-zone']);
|
||
|
|
my $current_zone = $zone_result->{out};
|
||
|
|
$current_zone =~ s/^\s+//;
|
||
|
|
$current_zone =~ s/\s+$//;
|
||
|
|
if ($current_zone ne 'public') {
|
||
|
|
if ($dry_run) {
|
||
|
|
my $shown = length $current_zone ? $current_zone : '?';
|
||
|
|
_status_done("would change from '$shown' to 'public'");
|
||
|
|
$info{zone_set} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $set_zone = run(['firewall-offline-cmd', '--set-default-zone=public']);
|
||
|
|
if ($set_zone->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to set the default zone to 'public'");
|
||
|
|
_warn('Aborting firewall setup BEFORE enabling firewalld');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done("changed to 'public' (was '$current_zone')");
|
||
|
|
$info{zone_set} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done("already 'public'");
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Lockout gate: never enable firewalld without confirmed SSH access ---
|
||
|
|
if (!$dry_run) {
|
||
|
|
my $verify = run(['firewall-offline-cmd', '--zone=public', '--query-service=ssh']);
|
||
|
|
if ($verify->{rc} != 0) {
|
||
|
|
_fail("Cannot confirm that SSH is allowed in zone 'public'");
|
||
|
|
_warn('Aborting firewall setup BEFORE enabling firewalld, '
|
||
|
|
. 'remote access would be at risk');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Enable and start, reached only with SSH confirmed ---
|
||
|
|
my $was_active = run(['systemctl', 'is-active', 'firewalld.service'])->{rc} == 0;
|
||
|
|
|
||
|
|
_status('Enabling firewalld service');
|
||
|
|
my $enabled = run(['systemctl', 'is-enabled', 'firewalld.service']);
|
||
|
|
if ($enabled->{rc} != 0) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would enable');
|
||
|
|
$info{enabled} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $enable = run(['systemctl', 'enable', 'firewalld.service']);
|
||
|
|
if ($enable->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to enable firewalld.service');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('enabled');
|
||
|
|
$info{enabled} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already enabled');
|
||
|
|
$info{enabled} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Starting firewalld service');
|
||
|
|
if (!$was_active) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would start');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $start = run(['systemctl', 'start', 'firewalld.service']);
|
||
|
|
if ($start->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to start firewalld.service');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('started');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already running');
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Runtime default zone: a daemon already running under another zone keeps it
|
||
|
|
# until it is switched at runtime. ---
|
||
|
|
if ($was_active && !$dry_run) {
|
||
|
|
my $runtime_zone = run(['firewall-cmd', '--get-default-zone']);
|
||
|
|
my $runtime_name = $runtime_zone->{out};
|
||
|
|
$runtime_name =~ s/^\s+//;
|
||
|
|
$runtime_name =~ s/\s+$//;
|
||
|
|
if ($runtime_zone->{rc} != 0) {
|
||
|
|
my $err = $runtime_zone->{err};
|
||
|
|
$err =~ s/^\s+//;
|
||
|
|
$err =~ s/\s+$//;
|
||
|
|
_warn("Cannot read the runtime default zone: $err");
|
||
|
|
}
|
||
|
|
elsif ($runtime_name ne 'public') {
|
||
|
|
_status("Switching runtime default zone to 'public'");
|
||
|
|
my $set_runtime = run(['firewall-cmd', '--set-default-zone=public']);
|
||
|
|
if ($set_runtime->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to switch the runtime default zone to 'public'");
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done("switched to 'public' (was '$runtime_name')");
|
||
|
|
$info{zone_set} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Reload only when a running daemon has drifted from the permanent config ---
|
||
|
|
if ($permanent_changed && $was_active) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would run: firewall-cmd --reload');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status('Reloading firewall');
|
||
|
|
my $reload = run(['firewall-cmd', '--reload']);
|
||
|
|
if ($reload->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to reload firewalld');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 4. SELinux
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub setup_selinux {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (
|
||
|
|
mode_changed => 0,
|
||
|
|
config_changed => 0,
|
||
|
|
utils_installed => 0,
|
||
|
|
current_mode => 'unknown',
|
||
|
|
reboot_required => 0,
|
||
|
|
failed => 0,
|
||
|
|
);
|
||
|
|
|
||
|
|
# --- Current mode ---
|
||
|
|
_status('Checking SELinux mode');
|
||
|
|
my $mode_result = run(['getenforce']);
|
||
|
|
if ($mode_result->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Cannot determine SELinux mode (getenforce failed or is missing)');
|
||
|
|
_info('On systems without SELinux, re-run with --skip-selinux');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $current_mode = $mode_result->{out};
|
||
|
|
$current_mode =~ s/^\s+//;
|
||
|
|
$current_mode =~ s/\s+$//;
|
||
|
|
$info{current_mode} = $current_mode;
|
||
|
|
_status_done($current_mode);
|
||
|
|
|
||
|
|
if ($current_mode eq 'Permissive') {
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would set SELinux to enforcing mode');
|
||
|
|
$info{mode_changed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status('Setting SELinux to enforcing');
|
||
|
|
my $enforce = run(['setenforce', '1']);
|
||
|
|
if ($enforce->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('setenforce 1 failed');
|
||
|
|
$info{failed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done();
|
||
|
|
$info{mode_changed} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elsif ($current_mode eq 'Disabled') {
|
||
|
|
# setenforce cannot work here, so the configuration and the relabel below
|
||
|
|
# carry the change instead.
|
||
|
|
$info{reboot_required} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Ensure SELINUX=enforcing in the configuration ---
|
||
|
|
_status('Checking /etc/selinux/config');
|
||
|
|
my $config_changed = 0;
|
||
|
|
my $config_content;
|
||
|
|
my $config_read = open(my $config_fh, '<', '/etc/selinux/config');
|
||
|
|
my $config_error = $config_read ? '' : os_error_text('/etc/selinux/config');
|
||
|
|
if ($config_read) {
|
||
|
|
$config_content = do { local $/ = undef; <$config_fh> };
|
||
|
|
close($config_fh);
|
||
|
|
}
|
||
|
|
if (!$config_read || !defined $config_content) {
|
||
|
|
_status_done('error');
|
||
|
|
_fail("Cannot read/write /etc/selinux/config: $config_error");
|
||
|
|
$info{failed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my @lines = split /\n/, $config_content, -1;
|
||
|
|
pop @lines if @lines && $lines[-1] eq ''; # a trailing newline is not a line
|
||
|
|
my $has_enforcing = 0;
|
||
|
|
for my $line (@lines) {
|
||
|
|
my $stripped = $line;
|
||
|
|
$stripped =~ s/^\s+//;
|
||
|
|
$stripped =~ s/\s+$//;
|
||
|
|
if (index($stripped, 'SELINUX=') == 0 && index($stripped, '#') != 0) {
|
||
|
|
my (undef, $value) = split /=/, $stripped, 2;
|
||
|
|
$value = '' unless defined $value;
|
||
|
|
$value =~ s/^\s+//;
|
||
|
|
$value =~ s/\s+$//;
|
||
|
|
$has_enforcing = 1 if lc($value) eq 'enforcing';
|
||
|
|
last;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$has_enforcing) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would update to SELINUX=enforcing');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my @new_lines;
|
||
|
|
my $found = 0;
|
||
|
|
for my $line (@lines) {
|
||
|
|
my $stripped = $line;
|
||
|
|
$stripped =~ s/^\s+//;
|
||
|
|
$stripped =~ s/\s+$//;
|
||
|
|
if (index($stripped, 'SELINUX=') == 0 && index($stripped, '#') != 0) {
|
||
|
|
push @new_lines, 'SELINUX=enforcing';
|
||
|
|
$found = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
push @new_lines, $line;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
push @new_lines, 'SELINUX=enforcing' unless $found;
|
||
|
|
my $ok = eval { atomic_write('/etc/selinux/config', join("\n", @new_lines) . "\n"); 1 };
|
||
|
|
if ($ok) {
|
||
|
|
_status_done('updated to enforcing');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $error = $@ || 'unknown error';
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
_status_done('error');
|
||
|
|
_fail("Cannot read/write /etc/selinux/config: $error");
|
||
|
|
$info{failed} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$config_changed = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already enforcing');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$info{config_changed} = $config_changed;
|
||
|
|
|
||
|
|
# --- Disabled to enforcing needs a reboot with a filesystem relabel ---
|
||
|
|
if ($current_mode eq 'Disabled') {
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would create /.autorelabel (relabel on next boot)');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status('Scheduling filesystem relabel on next boot');
|
||
|
|
my $ok = eval {
|
||
|
|
if (!-e '/.autorelabel') {
|
||
|
|
open(my $fh, '>', '/.autorelabel') or die os_error_text('/.autorelabel') . "\n";
|
||
|
|
close($fh);
|
||
|
|
}
|
||
|
|
1;
|
||
|
|
};
|
||
|
|
if ($ok) {
|
||
|
|
_status_done('/.autorelabel created');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $error = $@ || 'unknown error';
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Cannot create /.autorelabel: $error");
|
||
|
|
$info{failed} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_warn('SELinux is Disabled, enforcing mode requires a REBOOT; '
|
||
|
|
. 'the filesystem will be relabelled on next boot');
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Install policycoreutils-python-utils ---
|
||
|
|
_status('Checking policycoreutils-python-utils');
|
||
|
|
if (!rpm_installed('policycoreutils-python-utils')) {
|
||
|
|
_status_done('not installed');
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would install: policycoreutils-python-utils');
|
||
|
|
$info{utils_installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
if (dnf_install(['policycoreutils-python-utils'])) {
|
||
|
|
_ok('Installed policycoreutils-python-utils');
|
||
|
|
$info{utils_installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail('Failed to install policycoreutils-python-utils');
|
||
|
|
$info{failed} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already installed');
|
||
|
|
}
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 5. Podman
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub subid_entry_exists {
|
||
|
|
my ($path, $user) = @_;
|
||
|
|
open(my $fh, '<', $path) or return 0;
|
||
|
|
while (my $line = <$fh>) {
|
||
|
|
my ($name) = split /:/, $line, 2;
|
||
|
|
if (defined $name && $name eq $user) {
|
||
|
|
close($fh);
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
close($fh);
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub ensure_rootless_subids {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my $user = $ENV{SUDO_USER} // '';
|
||
|
|
if (!length $user || $user eq 'root') {
|
||
|
|
_info('No non-root invoking user, skipping rootless subuid/subgid setup');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
my $uid = getpwnam($user);
|
||
|
|
if (!defined $uid) {
|
||
|
|
_warn("Cannot look up user '$user', skipping subuid/subgid setup");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The range is derived from the user's UID so that a re-run is stable, and an
|
||
|
|
# existing entry is never modified.
|
||
|
|
my $offset = $uid - 1000;
|
||
|
|
$offset = 0 if $offset < 0;
|
||
|
|
my $start = 100000 + $offset * 65536;
|
||
|
|
my $id_range = "$start-" . ($start + 65535);
|
||
|
|
|
||
|
|
for my $item (['/etc/subuid', '--add-subuids'], ['/etc/subgid', '--add-subgids']) {
|
||
|
|
my ($path, $flag) = @$item;
|
||
|
|
if (subid_entry_exists($path, $user)) {
|
||
|
|
_info("$path: entry for '$user' already present");
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
if ($dry_run) {
|
||
|
|
_info("Would run: usermod $flag $id_range $user");
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
_status("Allocating subordinate IDs for '$user' in $path");
|
||
|
|
my $result = run(['usermod', $flag, $id_range, $user]);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done($id_range);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_warn("usermod $flag failed, rootless Podman may not work for '$user'");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub setup_podman {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (installed => 0, version => '', failed => 0);
|
||
|
|
|
||
|
|
_status('Checking Podman');
|
||
|
|
my $already = rpm_installed('podman');
|
||
|
|
|
||
|
|
if ($already) {
|
||
|
|
# Verify that it actually works.
|
||
|
|
my $ver = run(['podman', '--version']);
|
||
|
|
if ($ver->{rc} == 0) {
|
||
|
|
my $version = $ver->{out};
|
||
|
|
$version =~ s/^\s+//;
|
||
|
|
$version =~ s/\s+$//;
|
||
|
|
$info{version} = $version;
|
||
|
|
_status_done($info{version});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('installed but not working');
|
||
|
|
_warn("podman package is installed but 'podman --version' failed");
|
||
|
|
}
|
||
|
|
$info{installed} = 1;
|
||
|
|
ensure_rootless_subids($dry_run);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status_done('not installed');
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would install: podman');
|
||
|
|
ensure_rootless_subids($dry_run);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Installing Podman');
|
||
|
|
if (dnf_install(['podman'])) {
|
||
|
|
my $ver = run(['podman', '--version']);
|
||
|
|
my $version = $ver->{out};
|
||
|
|
$version =~ s/^\s+//;
|
||
|
|
$version =~ s/\s+$//;
|
||
|
|
$info{version} = $version;
|
||
|
|
_status_done($info{version});
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to install Podman');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
ensure_rootless_subids($dry_run);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 6. Automatic updates
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Apply the desired keys to the [commands] section of automatic.conf lines.
|
||
|
|
#
|
||
|
|
# Returns the new lines and whether anything changed. An existing key is rewritten
|
||
|
|
# only when its value differs; a missing key is appended at the end of the
|
||
|
|
# [commands] section; a missing [commands] section is created. Keys in other
|
||
|
|
# sections and comment lines are left untouched. The keys are applied in the
|
||
|
|
# order given.
|
||
|
|
sub render_dnf_automatic_conf {
|
||
|
|
my ($lines, $desired_order, $desired) = @_;
|
||
|
|
|
||
|
|
# A hand-edited config can lack the final newline, so it is normalised first:
|
||
|
|
# an appended key must never glue onto an unterminated last line.
|
||
|
|
my @lines = map { /\n\z/ ? $_ : "$_\n" } @$lines;
|
||
|
|
my @new_lines;
|
||
|
|
my %seen;
|
||
|
|
my ($in_commands, $found_commands, $changed) = (0, 0, 0);
|
||
|
|
|
||
|
|
# A key with no value in the desired set is not written at all: an empty
|
||
|
|
# assignment would be a configuration line that means nothing.
|
||
|
|
my $flush_missing = sub {
|
||
|
|
for my $key (@$desired_order) {
|
||
|
|
next if $seen{$key};
|
||
|
|
next unless defined $desired->{$key};
|
||
|
|
push @new_lines, "$key = $desired->{$key}\n";
|
||
|
|
$changed = 1;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
for my $line (@lines) {
|
||
|
|
my $stripped = $line;
|
||
|
|
$stripped =~ s/^\s+//;
|
||
|
|
$stripped =~ s/\s+$//;
|
||
|
|
if ($stripped =~ /^\[.*\]$/) {
|
||
|
|
if ($in_commands) {
|
||
|
|
$flush_missing->();
|
||
|
|
%seen = ();
|
||
|
|
}
|
||
|
|
$in_commands = lc($stripped) eq '[commands]' ? 1 : 0;
|
||
|
|
$found_commands = 1 if $in_commands;
|
||
|
|
push @new_lines, $line;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
if ($in_commands && index($stripped, '=') >= 0 && index($stripped, '#') != 0) {
|
||
|
|
my ($key, $raw_value) = split /=/, $stripped, 2;
|
||
|
|
$key =~ s/^\s+//;
|
||
|
|
$key =~ s/\s+$//;
|
||
|
|
if (exists $desired->{$key}) {
|
||
|
|
$seen{$key} = 1;
|
||
|
|
$raw_value = '' unless defined $raw_value;
|
||
|
|
$raw_value =~ s/^\s+//;
|
||
|
|
$raw_value =~ s/\s+$//;
|
||
|
|
if (lc($raw_value) ne lc($desired->{$key})) {
|
||
|
|
push @new_lines, "$key = $desired->{$key}\n";
|
||
|
|
$changed = 1;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
push @new_lines, $line;
|
||
|
|
}
|
||
|
|
|
||
|
|
$flush_missing->() if $in_commands;
|
||
|
|
if (!$found_commands) {
|
||
|
|
push @new_lines, "\n" if @new_lines && $new_lines[-1] =~ /\S/;
|
||
|
|
push @new_lines, "[commands]\n";
|
||
|
|
$flush_missing->();
|
||
|
|
}
|
||
|
|
|
||
|
|
return (\@new_lines, $changed);
|
||
|
|
}
|
||
|
|
|
||
|
|
sub setup_auto_updates {
|
||
|
|
my ($os_id, $dry_run) = @_;
|
||
|
|
my %info = (
|
||
|
|
installed => 0,
|
||
|
|
configured => 0,
|
||
|
|
timer_enabled => 0,
|
||
|
|
method => $os_id,
|
||
|
|
failed => 0,
|
||
|
|
);
|
||
|
|
|
||
|
|
if ($os_id eq 'fedora' || $os_id eq 'centos') {
|
||
|
|
# --- dnf-automatic, which dnf 5 provides under a virtual name ---
|
||
|
|
_status('Checking dnf-automatic (Fedora / CentOS Stream)');
|
||
|
|
my $pkg_present = rpm_installed('dnf-automatic') || rpm_installed('dnf5-plugin-automatic');
|
||
|
|
if (!$pkg_present) {
|
||
|
|
_status_done('not installed');
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would install: dnf-automatic');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
if (dnf_install(['dnf-automatic']) || dnf_install(['dnf5-plugin-automatic'])) {
|
||
|
|
_ok('Installed dnf-automatic');
|
||
|
|
$info{installed} = 1;
|
||
|
|
$pkg_present = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail('Failed to install dnf-automatic');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('installed');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Configure the file ---
|
||
|
|
_status("Configuring $DNF_AUTOMATIC_CONF");
|
||
|
|
my @desired_order = ('apply_updates', 'download_updates', 'upgrade_type');
|
||
|
|
my %desired = (
|
||
|
|
apply_updates => 'yes',
|
||
|
|
download_updates => 'yes',
|
||
|
|
upgrade_type => 'security',
|
||
|
|
);
|
||
|
|
my @config_lines;
|
||
|
|
my $open_ok = open(my $fh, '<', $DNF_AUTOMATIC_CONF);
|
||
|
|
my $open_error = $open_ok ? '' : os_error_text($DNF_AUTOMATIC_CONF);
|
||
|
|
if ($open_ok) {
|
||
|
|
my $content = do { local $/ = undef; <$fh> };
|
||
|
|
close($fh);
|
||
|
|
if (defined $content) {
|
||
|
|
@config_lines = map { "$_\n" } split /\n/, $content, -1;
|
||
|
|
pop @config_lines if @config_lines && $config_lines[-1] eq "\n";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elsif (-e $DNF_AUTOMATIC_CONF) {
|
||
|
|
# dnf 5 ships no file here and the host override is created from
|
||
|
|
# scratch, so only a file that exists and cannot be read is fatal.
|
||
|
|
_status_done('cannot read config');
|
||
|
|
_fail("Cannot read $DNF_AUTOMATIC_CONF: $open_error");
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
my ($rendered, $changed) = render_dnf_automatic_conf(\@config_lines, \@desired_order, \%desired);
|
||
|
|
if (!$changed) {
|
||
|
|
_status_done('already configured');
|
||
|
|
$info{configured} = 1;
|
||
|
|
}
|
||
|
|
elsif ($dry_run) {
|
||
|
|
_status_done('would update');
|
||
|
|
$info{configured} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $ok = eval { atomic_write($DNF_AUTOMATIC_CONF, join('', @$rendered)); 1 };
|
||
|
|
if (!$ok) {
|
||
|
|
my $error = $@ || 'unknown error';
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Cannot write $DNF_AUTOMATIC_CONF: $error");
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('updated');
|
||
|
|
$info{configured} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Pick the timer unit that exists, since the names differ by generation
|
||
|
|
_status('Detecting automatic update timer');
|
||
|
|
my $timer_name = '';
|
||
|
|
if ($pkg_present) {
|
||
|
|
for my $candidate (@AUTO_UPDATE_TIMER_CANDIDATES) {
|
||
|
|
if (run(['systemctl', 'cat', $candidate])->{rc} == 0) {
|
||
|
|
$timer_name = $candidate;
|
||
|
|
last;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!length $timer_name) {
|
||
|
|
if ($dry_run && !$pkg_present) {
|
||
|
|
_status_done('would detect after installation');
|
||
|
|
_info('Would enable and start the automatic update timer');
|
||
|
|
$info{timer_enabled} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('none found');
|
||
|
|
_fail('No automatic update timer found (tried: '
|
||
|
|
. join(', ', @AUTO_UPDATE_TIMER_CANDIDATES) . ')');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done($timer_name);
|
||
|
|
|
||
|
|
_status("Checking $timer_name");
|
||
|
|
my $timer_enabled = run(['systemctl', 'is-enabled', $timer_name]);
|
||
|
|
if ($timer_enabled->{rc} != 0) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would enable');
|
||
|
|
$info{timer_enabled} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $enable = run(['systemctl', 'enable', $timer_name]);
|
||
|
|
if ($enable->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to enable $timer_name");
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('enabled');
|
||
|
|
$info{timer_enabled} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already enabled');
|
||
|
|
$info{timer_enabled} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status("Starting $timer_name");
|
||
|
|
my $timer_active = run(['systemctl', 'is-active', $timer_name]);
|
||
|
|
if ($timer_active->{rc} != 0) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would start');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $start = run(['systemctl', 'start', $timer_name]);
|
||
|
|
if ($start->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to start $timer_name");
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('started');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already running');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elsif ($os_id eq 'openeuler') {
|
||
|
|
# --- dnf-hotpatch-plugin ---
|
||
|
|
_status('Checking dnf-hotpatch-plugin (openEuler)');
|
||
|
|
if (!rpm_installed('dnf-hotpatch-plugin')) {
|
||
|
|
_status_done('not installed');
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would install: dnf-hotpatch-plugin');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
if (dnf_install(['dnf-hotpatch-plugin'])) {
|
||
|
|
_ok('Installed dnf-hotpatch-plugin');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail('Failed to install dnf-hotpatch-plugin');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('installed');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Create or refresh the unit files ---
|
||
|
|
# Policy: openEuler applies all available updates, where the Fedora
|
||
|
|
# counterpart applies security updates only through dnf-automatic.
|
||
|
|
my $service_path = '/etc/systemd/system/auto-update.service';
|
||
|
|
my $service_content = <<'SERVICE';
|
||
|
|
[Unit]
|
||
|
|
Description=Automatic system updates
|
||
|
|
After=network-online.target
|
||
|
|
Wants=network-online.target
|
||
|
|
|
||
|
|
[Service]
|
||
|
|
Type=oneshot
|
||
|
|
# Apply all updates, then activate any kernel hot patches. The leading
|
||
|
|
# '-' keeps the unit successful when no hot patches are available.
|
||
|
|
ExecStart=/usr/bin/dnf update -y
|
||
|
|
ExecStart=-/usr/bin/dnf hotupgrade -y
|
||
|
|
SERVICE
|
||
|
|
my $timer_path = '/etc/systemd/system/auto-update.timer';
|
||
|
|
my $timer_content = <<'TIMER';
|
||
|
|
[Unit]
|
||
|
|
Description=Automatic system updates timer
|
||
|
|
|
||
|
|
[Timer]
|
||
|
|
OnCalendar=daily
|
||
|
|
RandomizedDelaySec=3600
|
||
|
|
Persistent=true
|
||
|
|
|
||
|
|
[Install]
|
||
|
|
WantedBy=timers.target
|
||
|
|
TIMER
|
||
|
|
|
||
|
|
my $needs_reload = 0;
|
||
|
|
for my $unit ([$service_path, $service_content], [$timer_path, $timer_content]) {
|
||
|
|
my ($unit_path, $unit_content) = @$unit;
|
||
|
|
my $unit_name = $unit_path;
|
||
|
|
$unit_name =~ s{.*/}{};
|
||
|
|
_status("Checking $unit_name (openEuler)");
|
||
|
|
my $existing;
|
||
|
|
if (open(my $fh, '<', $unit_path)) {
|
||
|
|
$existing = do { local $/ = undef; <$fh> };
|
||
|
|
close($fh);
|
||
|
|
}
|
||
|
|
if (defined $existing && $existing eq $unit_content) {
|
||
|
|
_status_done('up to date');
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done(!defined $existing ? 'would create' : 'would update (drift)');
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
my $ok = eval { atomic_write($unit_path, $unit_content); 1 };
|
||
|
|
if (!$ok) {
|
||
|
|
my $error = $@ || 'unknown error';
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Cannot write $unit_path: $error");
|
||
|
|
$info{failed} = 1;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
_status_done(!defined $existing ? 'created' : 'updated (content drift)');
|
||
|
|
$needs_reload = 1;
|
||
|
|
}
|
||
|
|
$info{configured} = 1;
|
||
|
|
|
||
|
|
# --- Reload systemd when unit files were created or refreshed ---
|
||
|
|
if ($needs_reload && !$dry_run) {
|
||
|
|
_status('Reloading systemd daemon');
|
||
|
|
my $daemon_reload = run(['systemctl', 'daemon-reload']);
|
||
|
|
if ($daemon_reload->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('systemctl daemon-reload failed');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('reloaded');
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Enable the timer ---
|
||
|
|
_status('Checking auto-update.timer (openEuler)');
|
||
|
|
my $timer_enabled = run(['systemctl', 'is-enabled', 'auto-update.timer']);
|
||
|
|
if ($timer_enabled->{rc} != 0) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would enable');
|
||
|
|
$info{timer_enabled} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $enable = run(['systemctl', 'enable', 'auto-update.timer']);
|
||
|
|
if ($enable->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to enable auto-update.timer');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('enabled');
|
||
|
|
$info{timer_enabled} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already enabled');
|
||
|
|
$info{timer_enabled} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Start the timer ---
|
||
|
|
_status('Starting auto-update.timer');
|
||
|
|
my $timer_active = run(['systemctl', 'is-active', 'auto-update.timer']);
|
||
|
|
if ($timer_active->{rc} != 0) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would start');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $start = run(['systemctl', 'start', 'auto-update.timer']);
|
||
|
|
if ($start->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to start auto-update.timer');
|
||
|
|
$info{failed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('started');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already running');
|
||
|
|
}
|
||
|
|
|
||
|
|
# --- Verify the hotpatch plugin, tri-state with undef meaning unchecked ---
|
||
|
|
$info{hotpatch_verified} = undef;
|
||
|
|
_status('Verifying dnf hotpatch plugin (openEuler)');
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('skipped (dry run)');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $hotpatch = run(['dnf', 'hot-updateinfo', 'list', 'cves', '--installed'], $DNF_TIMEOUT);
|
||
|
|
if ($hotpatch->{rc} == 0) {
|
||
|
|
_status_done('verified');
|
||
|
|
$info{hotpatch_verified} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_warn('dnf hot-updateinfo returned non-zero, hotpatch may not be functional');
|
||
|
|
$info{hotpatch_verified} = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Summary
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub print_summary {
|
||
|
|
my ($os_display, $version_id, $results, $warnings, $failures, $elapsed) = @_;
|
||
|
|
my $bar = "═" x 60;
|
||
|
|
|
||
|
|
print STDERR "\n${BOLD}── Setup Summary ──$RESET\n";
|
||
|
|
print STDERR " OS: $os_display $version_id\n";
|
||
|
|
|
||
|
|
# System update
|
||
|
|
my $update = $results->{update} // {};
|
||
|
|
if ($update->{failed}) {
|
||
|
|
_fail('System update failed');
|
||
|
|
}
|
||
|
|
elsif ($update->{updated}) {
|
||
|
|
_ok('System updated');
|
||
|
|
}
|
||
|
|
elsif ($update->{skipped}) {
|
||
|
|
_info('System already up to date');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('System update skipped');
|
||
|
|
}
|
||
|
|
_warn('Reboot required to fully apply updates') if $update->{reboot_required};
|
||
|
|
|
||
|
|
# Packages
|
||
|
|
my $packages = $results->{packages} // {};
|
||
|
|
my $installed = scalar @{ $packages->{installed} // [] };
|
||
|
|
my $skipped = scalar @{ $packages->{skipped} // [] };
|
||
|
|
my $failed_pkgs = scalar @{ $packages->{failed} // [] };
|
||
|
|
_ok("Packages: $skipped already present, $installed installed");
|
||
|
|
_fail("$failed_pkgs package(s) failed to install") if $failed_pkgs;
|
||
|
|
|
||
|
|
# Firewall
|
||
|
|
my $firewall = $results->{firewall} // {};
|
||
|
|
if ($firewall->{failed}) {
|
||
|
|
_fail('Firewall setup failed');
|
||
|
|
}
|
||
|
|
elsif (%$firewall) {
|
||
|
|
my $services = scalar @{ $firewall->{services_added} // [] };
|
||
|
|
my $services_skipped = scalar @{ $firewall->{services_skipped} // [] };
|
||
|
|
_ok("Firewall: $services service(s) added, $services_skipped already present");
|
||
|
|
}
|
||
|
|
|
||
|
|
# SELinux
|
||
|
|
my $selinux = $results->{selinux} // {};
|
||
|
|
if ($selinux->{failed}) {
|
||
|
|
_fail('SELinux setup failed');
|
||
|
|
}
|
||
|
|
elsif (%$selinux) {
|
||
|
|
_ok('SELinux: mode=' . ($selinux->{current_mode} // '?'));
|
||
|
|
}
|
||
|
|
if ($selinux->{reboot_required}) {
|
||
|
|
_warn('SELinux: reboot required, filesystem relabel scheduled (/.autorelabel)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# Podman
|
||
|
|
my $podman = $results->{podman} // {};
|
||
|
|
if ($podman->{failed}) {
|
||
|
|
_fail('Podman installation failed');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $podman_version = $podman->{version} // '';
|
||
|
|
if (length $podman_version) {
|
||
|
|
_ok("Podman: $podman_version");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Podman: not installed');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Automatic updates
|
||
|
|
my $auto = $results->{auto_updates} // {};
|
||
|
|
if ($auto->{failed}) {
|
||
|
|
_fail('Auto-updates setup failed');
|
||
|
|
}
|
||
|
|
elsif (%$auto) {
|
||
|
|
my $timer = $auto->{timer_enabled} ? 'enabled' : 'not enabled';
|
||
|
|
_ok("Auto-updates: timer $timer");
|
||
|
|
if (defined $auto->{hotpatch_verified}) {
|
||
|
|
my $hotpatch = $auto->{hotpatch_verified} ? 'verified' : 'not verified';
|
||
|
|
_info(" dnf hotpatch: $hotpatch");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (@$warnings) {
|
||
|
|
print STDERR "\n";
|
||
|
|
_warn($_) for @$warnings;
|
||
|
|
}
|
||
|
|
|
||
|
|
print STDERR "\n${BOLD}${bar}$RESET\n";
|
||
|
|
if (@$failures) {
|
||
|
|
_fail('Completed with failures in: ' . join(', ', @$failures));
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_ok('All sections completed successfully');
|
||
|
|
}
|
||
|
|
printf STDERR " %sTotal time: %.1fs%s\n", $BOLD, $elapsed, $RESET;
|
||
|
|
print STDERR "${BOLD}${bar}$RESET\n\n";
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Command line
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub usage {
|
||
|
|
my $name = $0;
|
||
|
|
$name =~ s{.*/}{};
|
||
|
|
return <<"USAGE";
|
||
|
|
Usage: $name [options]
|
||
|
|
|
||
|
|
Idempotent server setup for Fedora Server, CentOS Stream and openEuler
|
||
|
|
|
||
|
|
Options:
|
||
|
|
--dry-run Print what would be done without making changes
|
||
|
|
--skip-update Skip system update
|
||
|
|
--skip-packages Skip base package installation
|
||
|
|
--skip-epel Skip EPEL repository setup on CentOS
|
||
|
|
--skip-firewall Skip firewall setup
|
||
|
|
--skip-selinux Skip SELinux configuration
|
||
|
|
--skip-podman Skip Podman installation
|
||
|
|
--skip-auto-updates Skip automatic updates configuration
|
||
|
|
--version Show the version and exit
|
||
|
|
-h, --help Show this help and exit
|
||
|
|
USAGE
|
||
|
|
}
|
||
|
|
|
||
|
|
sub parse_args {
|
||
|
|
my %opt = (
|
||
|
|
dry_run => 0,
|
||
|
|
skip_update => 0,
|
||
|
|
skip_packages => 0,
|
||
|
|
skip_epel => 0,
|
||
|
|
skip_firewall => 0,
|
||
|
|
skip_selinux => 0,
|
||
|
|
skip_podman => 0,
|
||
|
|
skip_auto_updates => 0,
|
||
|
|
);
|
||
|
|
my %flag_for = (
|
||
|
|
'--dry-run' => 'dry_run',
|
||
|
|
'--skip-update' => 'skip_update',
|
||
|
|
'--skip-packages' => 'skip_packages',
|
||
|
|
'--skip-epel' => 'skip_epel',
|
||
|
|
'--skip-firewall' => 'skip_firewall',
|
||
|
|
'--skip-selinux' => 'skip_selinux',
|
||
|
|
'--skip-podman' => 'skip_podman',
|
||
|
|
'--skip-auto-updates' => 'skip_auto_updates',
|
||
|
|
);
|
||
|
|
my @argv = @ARGV;
|
||
|
|
while (defined(my $arg = shift @argv)) {
|
||
|
|
if (exists $flag_for{$arg}) { $opt{ $flag_for{$arg} } = 1; next }
|
||
|
|
if ($arg eq '--help' || $arg eq '-h') { print usage(); exit 0 }
|
||
|
|
if ($arg eq '--version') {
|
||
|
|
my $name = $0;
|
||
|
|
$name =~ s{.*/}{};
|
||
|
|
print "$name $VERSION\n";
|
||
|
|
exit 0;
|
||
|
|
}
|
||
|
|
print STDERR "unrecognised argument: $arg\n";
|
||
|
|
print STDERR usage();
|
||
|
|
exit 2;
|
||
|
|
}
|
||
|
|
return %opt;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Entry point
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub main {
|
||
|
|
my $start_time = now();
|
||
|
|
my @warnings;
|
||
|
|
my %results;
|
||
|
|
|
||
|
|
# Arguments first, so that --help and --version work for any user on any
|
||
|
|
# system.
|
||
|
|
my %opt = parse_args();
|
||
|
|
|
||
|
|
check_root();
|
||
|
|
my ($os_id, $os_display, $version_id) = detect_os();
|
||
|
|
|
||
|
|
my $bar = "═" x 60;
|
||
|
|
print STDERR "\n${BOLD}${bar}$RESET\n";
|
||
|
|
print STDERR "${BOLD} Server Setup v$VERSION ($os_display $version_id)$RESET\n";
|
||
|
|
print STDERR "${BOLD}${bar}$RESET\n";
|
||
|
|
if ($opt{dry_run}) {
|
||
|
|
print STDERR "\n ${YELLOW}${BOLD}DRY RUN: no changes will be made$RESET\n";
|
||
|
|
}
|
||
|
|
print STDERR "\n";
|
||
|
|
|
||
|
|
# 1. System update
|
||
|
|
print STDERR "\n${BOLD}── System Update ──$RESET\n";
|
||
|
|
if (!$opt{skip_update}) {
|
||
|
|
$results{update} = system_update($opt{dry_run});
|
||
|
|
if ($results{update}{reboot_required}) {
|
||
|
|
push @warnings, 'System updates require a reboot to take full effect';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('System update: skipped (--skip-update)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 2. EPEL repository, on CentOS Stream only
|
||
|
|
if ($os_id eq 'centos') {
|
||
|
|
print STDERR "\n${BOLD}── EPEL Repository ──$RESET\n";
|
||
|
|
if (!$opt{skip_epel}) {
|
||
|
|
$results{epel} = ensure_epel($opt{dry_run});
|
||
|
|
if ($results{epel}{failed}) {
|
||
|
|
push @warnings, 'EPEL repository setup failed, some packages may be unavailable';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('EPEL repository: skipped (--skip-epel)');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# 3. Base packages
|
||
|
|
print STDERR "\n${BOLD}── Base Packages ──$RESET\n";
|
||
|
|
if (!$opt{skip_packages}) {
|
||
|
|
$results{packages} = install_packages($opt{dry_run});
|
||
|
|
if (@{ $results{packages}{failed} }) {
|
||
|
|
push @warnings, 'Some packages failed to install: '
|
||
|
|
. join(', ', @{ $results{packages}{failed} });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Base packages: skipped (--skip-packages)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 4. Firewall
|
||
|
|
print STDERR "\n${BOLD}── Firewall ──$RESET\n";
|
||
|
|
if (!$opt{skip_firewall}) {
|
||
|
|
$results{firewall} = setup_firewall($opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Firewall: skipped (--skip-firewall)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 5. SELinux
|
||
|
|
print STDERR "\n${BOLD}── SELinux ──$RESET\n";
|
||
|
|
if (!$opt{skip_selinux}) {
|
||
|
|
$results{selinux} = setup_selinux($opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('SELinux: skipped (--skip-selinux)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 6. Podman
|
||
|
|
print STDERR "\n${BOLD}── Podman ──$RESET\n";
|
||
|
|
if (!$opt{skip_podman}) {
|
||
|
|
$results{podman} = setup_podman($opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Podman: skipped (--skip-podman)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 7. Automatic updates
|
||
|
|
print STDERR "\n${BOLD}── Automatic Updates ──$RESET\n";
|
||
|
|
if (!$opt{skip_auto_updates}) {
|
||
|
|
$results{auto_updates} = setup_auto_updates($os_id, $opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Auto-updates: skipped (--skip-auto-updates)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# Any truthy "failed" value counts: a flag, or a non-empty package list. An
|
||
|
|
# empty list is a list that is true in Perl, so it is counted by its length.
|
||
|
|
# The sections are named in the order they ran, so the failure list reads in
|
||
|
|
# that order too.
|
||
|
|
my @section_order = qw(update epel packages firewall selinux podman auto_updates);
|
||
|
|
my @failures;
|
||
|
|
for my $name (@section_order) {
|
||
|
|
next unless exists $results{$name};
|
||
|
|
my $failed = $results{$name}{failed};
|
||
|
|
next unless defined $failed;
|
||
|
|
my $is_failed = ref $failed eq 'ARRAY' ? scalar(@$failed) > 0 : ($failed ? 1 : 0);
|
||
|
|
push @failures, $name if $is_failed;
|
||
|
|
}
|
||
|
|
|
||
|
|
my $elapsed = now() - $start_time;
|
||
|
|
print_summary($os_display, $version_id, \%results, \@warnings, \@failures, $elapsed);
|
||
|
|
|
||
|
|
exit 1 if @failures;
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
$SIG{INT} = sub {
|
||
|
|
print STDERR "\nInterrupted.\n";
|
||
|
|
remove_scratch();
|
||
|
|
exit 130;
|
||
|
|
};
|
||
|
|
$SIG{TERM} = sub {
|
||
|
|
remove_scratch();
|
||
|
|
exit 143;
|
||
|
|
};
|
||
|
|
END {
|
||
|
|
remove_scratch();
|
||
|
|
}
|
||
|
|
|
||
|
|
# Only when this file is the program: a test harness may require it and call the
|
||
|
|
# pure functions directly.
|
||
|
|
exit(main()) unless caller;
|