#!/usr/bin/env perl # Copyright (c) 2026 Petr Balvín (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.1.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. # caddy is the reverse proxy the deploy scripts serve the endpoints through; # openEuler ships no package for it, and install_packages says so and leaves # it to the script that needs it. my @BASE_PACKAGES = qw( nano curl wget htop tmux rsync caddy 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 caddy is installed, which it is by default # wherever the package exists. my @CADDY_FIREWALL_SERVICES = ('http', 'https'); # The one base package a distribution's repositories do not carry, with the # reason and who provides it instead. my %UNPACKAGED = ( openeuler => { caddy => 'openEuler ships no caddy package; the scripts that need the ' . 'proxy install the release binary themselves', }, ); # Test-visible accessors: the catalogue is lexical to this file, so the checks # under tests/ read the base package list and the unpackaged map through these. sub base_packages { return @BASE_PACKAGES; } sub unpackaged_for { my ($os_id) = @_; return () unless exists $UNPACKAGED{$os_id}; return %{ $UNPACKAGED{$os_id} }; } # 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 ($os_id, $dry_run) = @_; my (@installed, @skipped, @failed, @to_install, @unpackaged); _status('Checking base packages'); for my $pkg (@BASE_PACKAGES) { # exists and not a bare lookup: descending into a missing os key # would autovivify it. if (exists $UNPACKAGED{$os_id} && exists $UNPACKAGED{$os_id}{$pkg}) { push @unpackaged, [$pkg, $UNPACKAGED{$os_id}{$pkg}]; next; } 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 - scalar @unpackaged; _status_done("$already/$total already installed"); for my $entry (@unpackaged) { my ($pkg, $reason) = @$entry; _info("$pkg is not packaged on this distribution: $reason"); } 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 caddy is present. my @services = @FIREWALL_SERVICES; push @services, @CADDY_FIREWALL_SERVICES if rpm_installed('caddy'); # --- 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($os_id, $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;