Files
scripts/server-setup.pl
T
petrbalvin 108a166ee0
Test / test (push) Successful in 1m14s
feat(server-setup): replace nginx with caddy
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:44 +02:00

1895 lines
63 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.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;