2395 lines
77 KiB
Perl
2395 lines
77 KiB
Perl
#!/usr/bin/env perl
|
|||
|
|
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||
|
|
# SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
# Idempotent workstation setup for Fedora: development tools, editors
|
||
|
|
# and multimedia.
|
||
|
|
#
|
||
|
|
# Every operation checks the current state before acting, so running the script
|
||
|
|
# again produces the same result with no errors and no repeated work.
|
||
|
|
#
|
||
|
|
# Supply-chain posture:
|
||
|
|
# - Go: the latest stable tarball from go.dev, SHA-256 verified against the
|
||
|
|
# checksum published in go.dev's official download API.
|
||
|
|
# - JetBrains IDEs: the latest release tarballs from download.jetbrains.com,
|
||
|
|
# SHA-256 verified against the release's published checksum.
|
||
|
|
# - Brave: the repo signing key is fingerprint-verified against the official
|
||
|
|
# fingerprints from https://brave.com/signing-keys/ before rpm --import.
|
||
|
|
#
|
||
|
|
# Perl builtins only: no module has to be installed. Three pieces of work Perl
|
||
|
|
# does not carry as builtins are written out here:
|
||
|
|
#
|
||
|
|
# * the command runners, which fork and keep stdout and stderr apart in the
|
||
|
|
# scratch directory;
|
||
|
|
# * a JSON decoder, for the go.dev download API and the JetBrains releases API;
|
||
|
|
# * SHA-256, which has no builtin and is therefore read from sha256sum. The
|
||
|
|
# digest is the same either way; only the transport differs.
|
||
|
|
#
|
||
|
|
# External binaries used: dnf, rpm, curl, tar, sha256sum, gpg, flatpak, systemctl,
|
||
|
|
# getenforce, setenforce, sudo, sync, rm and uname.
|
||
|
|
#
|
||
|
|
# Usage:
|
||
|
|
# workstation-setup.pl # full setup
|
||
|
|
# workstation-setup.pl --dry-run # preview without changes
|
||
|
|
# workstation-setup.pl --skip-update # skip system update
|
||
|
|
# workstation-setup.pl --skip-rpm # skip RPM package installation
|
||
|
|
# workstation-setup.pl --skip-flatpak # skip Flatpak apps
|
||
|
|
# workstation-setup.pl --skip-repos # skip adding third-party repos
|
||
|
|
# workstation-setup.pl --skip-remove # skip removing pre-installed apps
|
||
|
|
# workstation-setup.pl --skip-go # skip Go toolchain
|
||
|
|
# workstation-setup.pl --skip-rust # skip Rust toolchain
|
||
|
|
# workstation-setup.pl --skip-jetbrains # skip JetBrains IDE installation
|
||
|
|
# workstation-setup.pl --skip-firewall # skip firewall setup
|
||
|
|
# workstation-setup.pl --skip-selinux # skip SELinux setup
|
||
|
|
# workstation-setup.pl --version
|
||
|
|
|
||
|
|
use strict;
|
||
|
|
use warnings;
|
||
|
|
|
||
|
|
my $VERSION = '2.0.0';
|
||
|
|
|
||
|
|
my $BOLD = "\033[1m";
|
||
|
|
my $RED = "\033[31m";
|
||
|
|
my $GREEN = "\033[32m";
|
||
|
|
my $YELLOW = "\033[33m";
|
||
|
|
my $DIM = "\033[2m";
|
||
|
|
my $RESET = "\033[0m";
|
||
|
|
|
||
|
|
# dnf transactions, and a fresh install downloading hundreds of packages, need far
|
||
|
|
# more than a generic 60-second timeout.
|
||
|
|
my $DNF_TIMEOUT = 1800;
|
||
|
|
my $DNF_UPDATE_TIMEOUT = 3600;
|
||
|
|
|
||
|
|
# Go comes from the official go.dev tarball, not the Fedora RPM, which lags behind.
|
||
|
|
# The download API returns the latest stable release with per-file SHA-256 sums.
|
||
|
|
my $GO_DL_API_URL = 'https://go.dev/dl/?mode=json';
|
||
|
|
my $GO_TARBALL_BASE_URL = 'https://go.dev/dl/';
|
||
|
|
my $GO_INSTALL_DIR = '/usr/local/go';
|
||
|
|
my @GO_BASHRC_LINES = ('export PATH="/usr/local/go/bin:$PATH"');
|
||
|
|
|
||
|
|
# JetBrains IDEs come from the latest release tarball, resolved through the official
|
||
|
|
# releases API, installed system-wide under /opt with a launcher and a menu entry.
|
||
|
|
my $JETBRAINS_RELEASES_URL = 'https://data.services.jetbrains.com/products/releases';
|
||
|
|
my $JETBRAINS_INSTALL_ROOT = '/opt';
|
||
|
|
my $JETBRAINS_BIN_DIR = '/usr/local/bin';
|
||
|
|
my $JETBRAINS_DESKTOP_DIR = '/usr/local/share/applications';
|
||
|
|
|
||
|
|
# The IDE set, in the order the sections report them.
|
||
|
|
my @JETBRAINS_ORDER = ('GO');
|
||
|
|
my %JETBRAINS_IDES = (
|
||
|
|
GO => 'GoLand',
|
||
|
|
);
|
||
|
|
|
||
|
|
my $BRAVE_REPO_URL = 'https://brave-browser-rpm-release.s3.brave.com/brave-browser.repo';
|
||
|
|
my $BRAVE_KEY_URL = 'https://brave-browser-rpm-release.s3.brave.com/brave-core.asc';
|
||
|
|
my $BRAVE_REPO_FILE = '/etc/yum.repos.d/brave-browser.repo';
|
||
|
|
my $BRAVE_KEY_FILE = '/etc/pki/rpm-gpg/RPM-GPG-KEY-brave-browser';
|
||
|
|
|
||
|
|
# Primary-key fingerprints of the official Brave "Linux Package Repositories, Release
|
||
|
|
# Channel" keys, from https://brave.com/signing-keys/ (the 2023 key plus the 2025
|
||
|
|
# rotations). If Brave rotates again, verification fails loudly: update this set
|
||
|
|
# from the same page rather than weakening the check.
|
||
|
|
my %BRAVE_KEY_FINGERPRINTS = map { $_ => 1 } (
|
||
|
|
'DBF1A116C220B8C7164F98230686B78420038257', # Brave Linux Release (2023)
|
||
|
|
'47D32A74E9A9E013A4B4926C68D513D36A73CD96', # Brave Linux Release (2025, _mrk)
|
||
|
|
'B2A3DCA350E67256740DF904DE4EC67BE4B0DCA0', # Brave Linux Release (2025, _v2)
|
||
|
|
);
|
||
|
|
|
||
|
|
# Supported systems, in the order the messages list them. The workstation is a
|
||
|
|
# Fedora desktop; the server-facing scripts are the ones that carry the other
|
||
|
|
# systems.
|
||
|
|
my @SUPPORTED_ORDER = ('fedora');
|
||
|
|
my %SUPPORTED_OS = (
|
||
|
|
fedora => 'Fedora',
|
||
|
|
);
|
||
|
|
|
||
|
|
# Pre-installed packages to remove, skipped when already absent.
|
||
|
|
my @REMOVE_PACKAGES = qw(firefox gnome-system-monitor yelp mediawriter);
|
||
|
|
|
||
|
|
# RPM packages to install. Go is deliberately absent: the toolchain comes from the
|
||
|
|
# official go.dev tarball instead.
|
||
|
|
my @RPM_PACKAGES = qw(brave-browser git rustup just);
|
||
|
|
|
||
|
|
# Flatpak applications to install.
|
||
|
|
my @FLATPAK_APPS = qw(
|
||
|
|
org.remmina.Remmina org.telegram.desktop fr.handbrake.ghb com.rustdesk.RustDesk
|
||
|
|
org.gimp.GIMP net.mediaarea.MediaInfo net.nokyan.Resources app.drey.EarTag
|
||
|
|
org.bunkus.mkvtoolnix-gui org.fedoraproject.MediaWriter org.gnome.Firmware
|
||
|
|
);
|
||
|
|
|
||
|
|
# RPM counterparts of the Flatpak apps we install: when such an RPM is present it is
|
||
|
|
# removed first, so each application has a single source of truth. Apps without an
|
||
|
|
# official Fedora RPM counterpart (net.nokyan.Resources, app.drey.EarTag) are absent
|
||
|
|
# on purpose.
|
||
|
|
my @FLATPAK_RPM_ORDER = qw(
|
||
|
|
org.remmina.Remmina org.telegram.desktop fr.handbrake.ghb com.rustdesk.RustDesk
|
||
|
|
org.gimp.GIMP net.mediaarea.MediaInfo org.bunkus.mkvtoolnix-gui
|
||
|
|
org.fedoraproject.MediaWriter org.gnome.Firmware
|
||
|
|
);
|
||
|
|
my %FLATPAK_RPM_ALTERNATIVES = (
|
||
|
|
'org.remmina.Remmina' => 'remmina',
|
||
|
|
'org.telegram.desktop' => 'telegram-desktop',
|
||
|
|
'fr.handbrake.ghb' => 'HandBrake-gui',
|
||
|
|
'com.rustdesk.RustDesk' => 'rustdesk',
|
||
|
|
'org.gimp.GIMP' => 'gimp',
|
||
|
|
'net.mediaarea.MediaInfo' => 'mediainfo',
|
||
|
|
'org.bunkus.mkvtoolnix-gui' => 'mkvtoolnix-gui',
|
||
|
|
'org.fedoraproject.MediaWriter' => 'mediawriter',
|
||
|
|
'org.gnome.Firmware' => 'gnome-firmware',
|
||
|
|
);
|
||
|
|
|
||
|
|
# Flatpak counterparts of the RPM packages we install: uninstalled first.
|
||
|
|
my %RPM_FLATPAK_ALTERNATIVES = (
|
||
|
|
'brave-browser' => 'com.brave.Browser',
|
||
|
|
);
|
||
|
|
|
||
|
|
# 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, 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Commands, files and small helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub scratch_dir {
|
||
|
|
return $TMP_DIR if defined $TMP_DIR;
|
||
|
|
my $base = $ENV{TMPDIR} // '/tmp';
|
||
|
|
for my $attempt (0 .. 9) {
|
||
|
|
my $dir = "$base/workstation-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;
|
||
|
|
# Only the process that created the directory may remove it: a forked child
|
||
|
|
# inherits the END block.
|
||
|
|
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 : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
# Run a command and return { rc, out, err }.
|
||
|
|
#
|
||
|
|
# The locale is forced to C for English output parsing. A timeout, a missing binary
|
||
|
|
# and a failed exec become rc 124, 127 and 126 rather than exceptions, so callers
|
||
|
|
# always have a result to inspect. With use_sudo the command is run through sudo.
|
||
|
|
sub run {
|
||
|
|
my ($cmd, %opt) = @_;
|
||
|
|
my $timeout = $opt{timeout} // 60;
|
||
|
|
my @full = $opt{use_sudo} ? ('sudo', @$cmd) : @$cmd;
|
||
|
|
|
||
|
|
my $exe = find_exe($full[0]);
|
||
|
|
return { rc => 127, out => '', err => "command not found: $full[0]" } unless defined $exe;
|
||
|
|
|
||
|
|
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 } @full;
|
||
|
|
}
|
||
|
|
exit 126;
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
# A child killed by a signal must not look like success: $? >> 8 is 0
|
||
|
|
# for a signalled exit, so the signal becomes a shell-style 128+n code.
|
||
|
|
my $signal = $? & 127;
|
||
|
|
$rc = $signal ? 128 + $signal : ($? >> 8);
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
# The command list that reaches the target command as the real user: the sudo
|
||
|
|
# prefix with optional env(1) assignments, then the command itself as one flat
|
||
|
|
# list. Building the list separately is what keeps the command's arguments out
|
||
|
|
# of the runner's option hash.
|
||
|
|
sub user_command {
|
||
|
|
my ($cmd, $env) = @_;
|
||
|
|
my $sudo_user = $ENV{SUDO_USER} // '';
|
||
|
|
return [@$cmd] unless length $sudo_user;
|
||
|
|
my @full = ('sudo', '-u', $sudo_user);
|
||
|
|
if ($env && %$env) {
|
||
|
|
push @full, 'env', map { "$_=$env->{$_}" } sort keys %$env;
|
||
|
|
}
|
||
|
|
push @full, @$cmd;
|
||
|
|
return \@full;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Run a command as the original user rather than as root, when the script was
|
||
|
|
# started through sudo. Extra environment variables go through env(1) so they
|
||
|
|
# survive sudo's environment reset.
|
||
|
|
sub run_as_user {
|
||
|
|
my ($cmd, %opt) = @_;
|
||
|
|
return run(user_command($cmd, $opt{env}), timeout => $opt{timeout} // 60);
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# OS detection
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Section helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub rpm_installed {
|
||
|
|
my ($pkg) = @_;
|
||
|
|
return run(['rpm', '-q', $pkg])->{rc} == 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub have_flatpak {
|
||
|
|
my ($app) = @_;
|
||
|
|
return run(['flatpak', 'info', $app])->{rc} == 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub flatpak_scopes {
|
||
|
|
my ($app) = @_;
|
||
|
|
my @found;
|
||
|
|
push @found, 'system' if run(['flatpak', 'info', $app])->{rc} == 0;
|
||
|
|
push @found, 'user' if run_as_user(['flatpak', 'info', '--user', $app])->{rc} == 0;
|
||
|
|
return @found;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub remove_conflicting_rpm {
|
||
|
|
my ($app, $dry_run) = @_;
|
||
|
|
my $rpm_name = $FLATPAK_RPM_ALTERNATIVES{$app};
|
||
|
|
return undef unless defined $rpm_name;
|
||
|
|
return undef unless rpm_installed($rpm_name);
|
||
|
|
_warn("$app: also installed as RPM '$rpm_name', removing it to avoid a duplicate");
|
||
|
|
if ($dry_run) {
|
||
|
|
_info("Would remove RPM package: $rpm_name");
|
||
|
|
return $rpm_name;
|
||
|
|
}
|
||
|
|
my $result = run(['dnf', 'remove', '-y', $rpm_name], timeout => $DNF_TIMEOUT, use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_ok("Removed RPM package: $rpm_name");
|
||
|
|
return $rpm_name;
|
||
|
|
}
|
||
|
|
_fail("Failed to remove RPM package $rpm_name");
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub remove_conflicting_flatpak {
|
||
|
|
my ($pkg, $dry_run) = @_;
|
||
|
|
my $app_id = $RPM_FLATPAK_ALTERNATIVES{$pkg};
|
||
|
|
return undef unless defined $app_id;
|
||
|
|
my $removed = 0;
|
||
|
|
for my $scope (flatpak_scopes($app_id)) {
|
||
|
|
_warn("$pkg: also installed as Flatpak '$app_id' ($scope), removing it to avoid a duplicate");
|
||
|
|
if ($dry_run) {
|
||
|
|
_info("Would uninstall Flatpak app: $app_id ($scope)");
|
||
|
|
$removed = 1;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
my $result = $scope eq 'user'
|
||
|
|
? run_as_user(['flatpak', 'uninstall', '-y', '--user', $app_id], timeout => 300)
|
||
|
|
: run(['flatpak', 'uninstall', '-y', $app_id], timeout => 300, use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_ok("Uninstalled Flatpak app: $app_id ($scope)");
|
||
|
|
$removed = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail("Failed to uninstall Flatpak app $app_id ($scope)");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return $removed ? $app_id : undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The passwd entry of the real, non-root user: nothing to drop to means the script
|
||
|
|
# runs in a real root shell, or SUDO_USER does not resolve.
|
||
|
|
sub real_user {
|
||
|
|
my $sudo_user = $ENV{SUDO_USER} // '';
|
||
|
|
if (length $sudo_user) {
|
||
|
|
my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir) = getpwnam($sudo_user);
|
||
|
|
return undef unless defined $uid;
|
||
|
|
return { name => $name, uid => $uid, gid => $gid, dir => $dir };
|
||
|
|
}
|
||
|
|
return undef if $> == 0;
|
||
|
|
my ($name, $passwd, $uid, $gid, $quota, $comment, $gcos, $dir) = getpwuid($>);
|
||
|
|
return undef unless defined $uid;
|
||
|
|
return { name => $name, uid => $uid, gid => $gid, dir => $dir };
|
||
|
|
}
|
||
|
|
|
||
|
|
# The errno, the reason and the path, so a failure message names all three.
|
||
|
|
sub os_error_text {
|
||
|
|
my ($path) = @_;
|
||
|
|
return "[Errno " . (0 + $!) . "] $!: '$path'";
|
||
|
|
}
|
||
|
|
|
||
|
|
sub fsync_path {
|
||
|
|
my ($path) = @_;
|
||
|
|
my $sync = find_exe('sync');
|
||
|
|
return unless defined $sync;
|
||
|
|
system { $sync } $sync, $path;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Replace a file with new content, preserving its mode and owner.
|
||
|
|
#
|
||
|
|
# /etc/selinux/config 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. Perl's
|
||
|
|
# builtins expose no fsync, so that barrier is delegated to sync(1) where it exists.
|
||
|
|
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";
|
||
|
|
1;
|
||
|
|
};
|
||
|
|
if (!$ok) {
|
||
|
|
my $error = $@ || 'unknown error';
|
||
|
|
unlink($tmp);
|
||
|
|
die $error;
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub real_home {
|
||
|
|
my $real = real_user();
|
||
|
|
return $real->{dir} if $real;
|
||
|
|
my $home = $ENV{HOME} // '';
|
||
|
|
return length $home ? $home : '/root';
|
||
|
|
}
|
||
|
|
|
||
|
|
sub chown_user {
|
||
|
|
my ($path) = @_;
|
||
|
|
my $real = real_user();
|
||
|
|
return unless $real;
|
||
|
|
chown($real->{uid}, $real->{gid}, $path);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Absolute path to a tool: PATH first, then the user's home locations.
|
||
|
|
sub tool_path {
|
||
|
|
my ($name, @home_rel) = @_;
|
||
|
|
my $found = find_exe($name);
|
||
|
|
return $found if defined $found;
|
||
|
|
my $home = real_home();
|
||
|
|
for my $rel (@home_rel) {
|
||
|
|
my $candidate = "$home/$rel";
|
||
|
|
return $candidate if -f $candidate && -x _;
|
||
|
|
}
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub probe_version {
|
||
|
|
my ($cmd, %opt) = @_;
|
||
|
|
my $as_user = exists $opt{as_user} ? $opt{as_user} : 1;
|
||
|
|
my $result = $as_user ? run_as_user($cmd) : run($cmd);
|
||
|
|
return 'unknown' if $result->{rc} != 0;
|
||
|
|
my $out = $result->{out};
|
||
|
|
$out =~ s/^\s+//;
|
||
|
|
$out =~ s/\s+$//;
|
||
|
|
return 'unknown' unless length $out;
|
||
|
|
my ($first) = split /\n/, $out;
|
||
|
|
return $first;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub download {
|
||
|
|
my ($url, $dest, %opt) = @_;
|
||
|
|
my $timeout = $opt{timeout} // 300;
|
||
|
|
return run(['curl', '-fsSL', '-o', $dest, $url], timeout => $timeout, use_sudo => 1)->{rc} == 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The SHA-256 digest of a file. Builtins have none, so sha256sum is the transport and
|
||
|
|
# the digest is compared exactly as before.
|
||
|
|
sub sha256_file {
|
||
|
|
my ($path) = @_;
|
||
|
|
my $result = run(['sha256sum', '--', $path], timeout => 900);
|
||
|
|
return '' if $result->{rc} != 0;
|
||
|
|
my @fields = split ' ', $result->{out};
|
||
|
|
return @fields ? lc $fields[0] : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
sub sha256_from_sums {
|
||
|
|
my ($sums_file, $filename) = @_;
|
||
|
|
open(my $fh, '<', $sums_file) or return undef;
|
||
|
|
while (my $line = <$fh>) {
|
||
|
|
my @parts = split ' ', $line;
|
||
|
|
next unless @parts == 2;
|
||
|
|
my $name = $parts[1];
|
||
|
|
$name =~ s/^\*//;
|
||
|
|
$name =~ s/\s+$//;
|
||
|
|
if ($name eq $filename) {
|
||
|
|
close($fh);
|
||
|
|
return lc $parts[0];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
close($fh);
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Fingerprints of the primary keys in an ASCII-armoured key file, through gpg. Only
|
||
|
|
# the fpr record that directly follows a pub record counts: subkey fingerprints are
|
||
|
|
# ignored, so the result compares against a set of primary keys.
|
||
|
|
sub asc_fingerprints {
|
||
|
|
my ($key_file) = @_;
|
||
|
|
return () unless defined find_exe('gpg');
|
||
|
|
my $result = run(['gpg', '--show-keys', '--with-colons', $key_file]);
|
||
|
|
return () unless $result->{rc} == 0;
|
||
|
|
my (@fingerprints, $last_record);
|
||
|
|
for my $line (split /\n/, $result->{out}) {
|
||
|
|
my @parts = split /:/, $line, -1;
|
||
|
|
next if @parts < 10;
|
||
|
|
if ($parts[0] eq 'pub' || $parts[0] eq 'sub') {
|
||
|
|
$last_record = $parts[0];
|
||
|
|
}
|
||
|
|
elsif ($parts[0] eq 'fpr' && ($last_record // '') eq 'pub' && length $parts[9]) {
|
||
|
|
push @fingerprints, uc $parts[9];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return @fingerprints;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Copy a file with builtins, replacing the destination.
|
||
|
|
sub copy_file {
|
||
|
|
my ($from, $to) = @_;
|
||
|
|
my $content = slurp($from);
|
||
|
|
my $ok = eval {
|
||
|
|
open(my $fh, '>', $to) or die os_error_text($to) . "\n";
|
||
|
|
print {$fh} $content or die os_error_text($to) . "\n";
|
||
|
|
close($fh) or die os_error_text($to) . "\n";
|
||
|
|
1;
|
||
|
|
};
|
||
|
|
die $@ unless $ok;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
# A directory removed with everything under it.
|
||
|
|
sub remove_tree {
|
||
|
|
my ($path) = @_;
|
||
|
|
return unless -d $path;
|
||
|
|
if (opendir(my $dh, $path)) {
|
||
|
|
for my $entry (readdir($dh)) {
|
||
|
|
next if $entry eq '.' || $entry eq '..';
|
||
|
|
my $full = "$path/$entry";
|
||
|
|
if (-l $full) { unlink($full) }
|
||
|
|
elsif (-d $full) { remove_tree($full) }
|
||
|
|
else { unlink($full) }
|
||
|
|
}
|
||
|
|
closedir($dh);
|
||
|
|
}
|
||
|
|
rmdir($path);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub make_dirs {
|
||
|
|
my ($path, $mode) = @_;
|
||
|
|
$mode = 0755 unless defined $mode;
|
||
|
|
my @parts = split m{/}, $path;
|
||
|
|
my $current = '';
|
||
|
|
for my $part (@parts) {
|
||
|
|
next unless length $part;
|
||
|
|
$current .= "/$part";
|
||
|
|
next if -d $current;
|
||
|
|
mkdir($current, $mode) or return 0;
|
||
|
|
}
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# A temporary directory removed when the block ends, wherever it lives.
|
||
|
|
sub make_temp_dir {
|
||
|
|
my (%opt) = @_;
|
||
|
|
my $base = $opt{dir} // ($ENV{TMPDIR} // '/tmp');
|
||
|
|
my $prefix = $opt{prefix} // 'workstation-setup-';
|
||
|
|
for my $attempt (0 .. 9) {
|
||
|
|
my $dir = "$base/${prefix}$$.$attempt";
|
||
|
|
return $dir if mkdir($dir, 0700);
|
||
|
|
}
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# JSON, written by hand
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
#
|
||
|
|
# The go.dev download API and the JetBrains releases API both answer in JSON, and no
|
||
|
|
# JSON module may be assumed. The decoder covers the grammar the format allows.
|
||
|
|
|
||
|
|
sub json_decode {
|
||
|
|
my ($text) = @_;
|
||
|
|
$text = '' unless defined $text;
|
||
|
|
my $pos = 0;
|
||
|
|
my $value = json_parse_value($text, \$pos);
|
||
|
|
json_skip_space($text, \$pos);
|
||
|
|
die "trailing data at offset $pos\n" if $pos < length $text;
|
||
|
|
return $value;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub json_skip_space {
|
||
|
|
my ($text, $pos_ref) = @_;
|
||
|
|
my $length = length $text;
|
||
|
|
while ($$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[ \t\r\n]/) {
|
||
|
|
$$pos_ref++;
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub json_parse_value {
|
||
|
|
my ($text, $pos_ref) = @_;
|
||
|
|
json_skip_space($text, $pos_ref);
|
||
|
|
die "unexpected end of input at offset $$pos_ref\n" if $$pos_ref >= length $text;
|
||
|
|
my $char = substr($text, $$pos_ref, 1);
|
||
|
|
return json_parse_object($text, $pos_ref) if $char eq '{';
|
||
|
|
return json_parse_array($text, $pos_ref) if $char eq '[';
|
||
|
|
return json_parse_string($text, $pos_ref) if $char eq '"';
|
||
|
|
return json_parse_number($text, $pos_ref) if $char =~ /[-0-9]/;
|
||
|
|
for my $literal (['true', 1], ['false', 0], ['null', undef]) {
|
||
|
|
my ($word, $value) = @$literal;
|
||
|
|
if (substr($text, $$pos_ref, length $word) eq $word) {
|
||
|
|
$$pos_ref += length $word;
|
||
|
|
return $value;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
die "unrecognised token at offset $$pos_ref\n";
|
||
|
|
}
|
||
|
|
|
||
|
|
sub json_parse_object {
|
||
|
|
my ($text, $pos_ref) = @_;
|
||
|
|
my %object;
|
||
|
|
$$pos_ref++;
|
||
|
|
json_skip_space($text, $pos_ref);
|
||
|
|
if (substr($text, $$pos_ref, 1) eq '}') {
|
||
|
|
$$pos_ref++;
|
||
|
|
return \%object;
|
||
|
|
}
|
||
|
|
while (1) {
|
||
|
|
json_skip_space($text, $pos_ref);
|
||
|
|
die "expected a key at offset $$pos_ref\n" unless substr($text, $$pos_ref, 1) eq '"';
|
||
|
|
my $key = json_parse_string($text, $pos_ref);
|
||
|
|
json_skip_space($text, $pos_ref);
|
||
|
|
die "expected a colon at offset $$pos_ref\n" unless substr($text, $$pos_ref, 1) eq ':';
|
||
|
|
$$pos_ref++;
|
||
|
|
$object{$key} = json_parse_value($text, $pos_ref);
|
||
|
|
json_skip_space($text, $pos_ref);
|
||
|
|
my $next = substr($text, $$pos_ref, 1);
|
||
|
|
if ($next eq ',') { $$pos_ref++; next }
|
||
|
|
if ($next eq '}') { $$pos_ref++; last }
|
||
|
|
die "expected a comma or a closing brace at offset $$pos_ref\n";
|
||
|
|
}
|
||
|
|
return \%object;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub json_parse_array {
|
||
|
|
my ($text, $pos_ref) = @_;
|
||
|
|
my @items;
|
||
|
|
$$pos_ref++;
|
||
|
|
json_skip_space($text, $pos_ref);
|
||
|
|
if (substr($text, $$pos_ref, 1) eq ']') {
|
||
|
|
$$pos_ref++;
|
||
|
|
return \@items;
|
||
|
|
}
|
||
|
|
while (1) {
|
||
|
|
push @items, json_parse_value($text, $pos_ref);
|
||
|
|
json_skip_space($text, $pos_ref);
|
||
|
|
my $next = substr($text, $$pos_ref, 1);
|
||
|
|
if ($next eq ',') { $$pos_ref++; next }
|
||
|
|
if ($next eq ']') { $$pos_ref++; last }
|
||
|
|
die "expected a comma or a closing bracket at offset $$pos_ref\n";
|
||
|
|
}
|
||
|
|
return \@items;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub json_parse_string {
|
||
|
|
my ($text, $pos_ref) = @_;
|
||
|
|
$$pos_ref++;
|
||
|
|
my $out = '';
|
||
|
|
my $length = length $text;
|
||
|
|
my %simple = ('"' => '"', '\\' => '\\', '/' => '/', 'b' => "\b", 'f' => "\f",
|
||
|
|
'n' => "\n", 'r' => "\r", 't' => "\t");
|
||
|
|
while ($$pos_ref < $length) {
|
||
|
|
my $char = substr($text, $$pos_ref, 1);
|
||
|
|
if ($char eq '"') {
|
||
|
|
$$pos_ref++;
|
||
|
|
return $out;
|
||
|
|
}
|
||
|
|
if ($char ne '\\') {
|
||
|
|
$out .= $char;
|
||
|
|
$$pos_ref++;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
$$pos_ref++;
|
||
|
|
my $escape = substr($text, $$pos_ref, 1);
|
||
|
|
if (exists $simple{$escape}) {
|
||
|
|
$out .= $simple{$escape};
|
||
|
|
$$pos_ref++;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
die "unrecognised escape at offset $$pos_ref\n" unless $escape eq 'u';
|
||
|
|
my $hex = substr($text, $$pos_ref + 1, 4);
|
||
|
|
die "malformed \\u escape at offset $$pos_ref\n" unless $hex =~ /^[0-9a-fA-F]{4}$/;
|
||
|
|
my $code = hex $hex;
|
||
|
|
$$pos_ref += 5;
|
||
|
|
if ($code >= 0xd800 && $code <= 0xdbff && substr($text, $$pos_ref, 2) eq '\\u') {
|
||
|
|
my $low_hex = substr($text, $$pos_ref + 2, 4);
|
||
|
|
if ($low_hex =~ /^[0-9a-fA-F]{4}$/) {
|
||
|
|
my $low = hex $low_hex;
|
||
|
|
if ($low >= 0xdc00 && $low <= 0xdfff) {
|
||
|
|
$code = 0x10000 + (($code - 0xd800) << 10) + ($low - 0xdc00);
|
||
|
|
$$pos_ref += 6;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
my $bytes = pack('U', $code);
|
||
|
|
utf8::encode($bytes);
|
||
|
|
$out .= $bytes;
|
||
|
|
}
|
||
|
|
die "unterminated string\n";
|
||
|
|
}
|
||
|
|
|
||
|
|
sub json_parse_number {
|
||
|
|
my ($text, $pos_ref) = @_;
|
||
|
|
my $length = length $text;
|
||
|
|
my $start = $$pos_ref;
|
||
|
|
$$pos_ref++ if substr($text, $$pos_ref, 1) eq '-';
|
||
|
|
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
|
||
|
|
if ($$pos_ref < $length && substr($text, $$pos_ref, 1) eq '.') {
|
||
|
|
$$pos_ref++;
|
||
|
|
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
|
||
|
|
}
|
||
|
|
if ($$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[eE]/) {
|
||
|
|
$$pos_ref++;
|
||
|
|
$$pos_ref++ if substr($text, $$pos_ref, 1) =~ /[-+]/;
|
||
|
|
$$pos_ref++ while $$pos_ref < $length && substr($text, $$pos_ref, 1) =~ /[0-9]/;
|
||
|
|
}
|
||
|
|
my $literal = substr($text, $start, $$pos_ref - $start);
|
||
|
|
unless ($literal =~ /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/) {
|
||
|
|
die "malformed number at offset $start\n";
|
||
|
|
}
|
||
|
|
return $literal + 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 1. System update
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub system_update {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (updated => 0, skipped => 0, would_update => 0, error => 0);
|
||
|
|
|
||
|
|
_status('Checking for system updates');
|
||
|
|
my $check_result = run(['dnf', 'check-update'], timeout => 300, use_sudo => 1);
|
||
|
|
# dnf check-update: 0 means no updates, 100 means updates are available, anything
|
||
|
|
# else is an error.
|
||
|
|
if ($check_result->{rc} == 0) {
|
||
|
|
_status_done('already up to date');
|
||
|
|
$info{skipped} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
if ($check_result->{rc} != 100) {
|
||
|
|
_status_done('check failed');
|
||
|
|
my $error = $check_result->{err};
|
||
|
|
$error =~ s/^\s+//;
|
||
|
|
$error =~ s/\s+$//;
|
||
|
|
$error = 'unknown error' unless length $error;
|
||
|
|
_fail("dnf check-update failed: $error");
|
||
|
|
$info{error} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status_done('updates available');
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would run: dnf update -y');
|
||
|
|
$info{would_update} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Applying system updates');
|
||
|
|
my $update_result = run(['dnf', 'update', '-y'], timeout => $DNF_UPDATE_TIMEOUT, use_sudo => 1);
|
||
|
|
if ($update_result->{rc} == 0) {
|
||
|
|
_status_done();
|
||
|
|
$info{updated} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('System update returned non-zero exit code');
|
||
|
|
$info{error} = 1;
|
||
|
|
}
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 2. Remove pre-installed apps
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub remove_packages {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my (@removed, @skipped);
|
||
|
|
|
||
|
|
for my $pkg (@REMOVE_PACKAGES) {
|
||
|
|
_status("Checking $pkg");
|
||
|
|
if (!rpm_installed($pkg)) {
|
||
|
|
_status_done('not installed, skipping');
|
||
|
|
push @skipped, $pkg;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would remove');
|
||
|
|
push @removed, $pkg;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
my $result = run(['dnf', 'remove', '-y', $pkg], timeout => $DNF_TIMEOUT, use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done('removed');
|
||
|
|
push @removed, $pkg;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to remove $pkg");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { removed => \@removed, skipped => \@skipped };
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 3. Third-party RPM repos
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Download the Brave signing key, verify its fingerprints, import it, and keep the
|
||
|
|
# verified bytes beside the repo file.
|
||
|
|
sub do_import_brave_key {
|
||
|
|
if (!defined find_exe('gpg')) {
|
||
|
|
_fail('gpg not found, cannot verify the Brave signing key');
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
my $tmp = make_temp_dir();
|
||
|
|
return 0 unless defined $tmp;
|
||
|
|
my $key_file = "$tmp/brave-core.asc";
|
||
|
|
my $failed = 0;
|
||
|
|
|
||
|
|
if (!download($BRAVE_KEY_URL, $key_file, timeout => 60)) {
|
||
|
|
_fail('Failed to download the Brave signing key');
|
||
|
|
$failed = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
my @fingerprints = $failed ? () : asc_fingerprints($key_file);
|
||
|
|
if (!$failed && !@fingerprints) {
|
||
|
|
_fail('No keys found in the downloaded Brave key file');
|
||
|
|
$failed = 1;
|
||
|
|
}
|
||
|
|
# Every fingerprint in the file has to be one of the published keys.
|
||
|
|
my @unknown = grep { !$BRAVE_KEY_FINGERPRINTS{$_} } @fingerprints;
|
||
|
|
if (!$failed && @unknown) {
|
||
|
|
my @expected = sort keys %BRAVE_KEY_FINGERPRINTS;
|
||
|
|
_fail('Brave signing key fingerprint mismatch, expected one of: '
|
||
|
|
. join(', ', @expected) . '; got: ' . join(', ', sort @fingerprints));
|
||
|
|
$failed = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!$failed) {
|
||
|
|
_ok('Brave signing key fingerprint verified');
|
||
|
|
# Persist the verified bytes: the repo file's gpgkey= is pinned to this local
|
||
|
|
# copy, so dnf can only import exactly these keys.
|
||
|
|
my $key_dir = $BRAVE_KEY_FILE;
|
||
|
|
$key_dir =~ s{/[^/]+$}{};
|
||
|
|
if (make_dirs($key_dir)) {
|
||
|
|
my $copied = eval { copy_file($key_file, $BRAVE_KEY_FILE); 1 };
|
||
|
|
if ($copied) {
|
||
|
|
chmod(0644, $BRAVE_KEY_FILE);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $error = $@;
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
# A half-written copy must not become the pinned gpgkey= target.
|
||
|
|
unlink($BRAVE_KEY_FILE);
|
||
|
|
_warn("Could not store the verified key at $BRAVE_KEY_FILE: $error");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
my $import = run(['rpm', '--import', $key_file], use_sudo => 1);
|
||
|
|
if ($import->{rc} != 0) {
|
||
|
|
_fail('rpm --import failed for the Brave signing key');
|
||
|
|
$failed = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
remove_tree($tmp);
|
||
|
|
return $failed ? 0 : 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Pin the repo file's gpgkey= line to the local copy of the verified key. The
|
||
|
|
# paths are parameters so the rewrite can be exercised away from /etc.
|
||
|
|
sub pin_brave_repo_gpgkey {
|
||
|
|
my ($repo_file, $key_file) = @_;
|
||
|
|
$repo_file //= $BRAVE_REPO_FILE;
|
||
|
|
$key_file //= $BRAVE_KEY_FILE;
|
||
|
|
my $read_ok = open(my $repo_fh, '<', $repo_file);
|
||
|
|
my $read_error = $read_ok ? '' : os_error_text($repo_file);
|
||
|
|
my $content;
|
||
|
|
if ($read_ok) {
|
||
|
|
$content = do { local $/ = undef; <$repo_fh> };
|
||
|
|
close($repo_fh);
|
||
|
|
}
|
||
|
|
if (!$read_ok || !defined $content) {
|
||
|
|
_warn("Cannot read $repo_file: $read_error");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
my $pinned = "gpgkey=file://$key_file";
|
||
|
|
return 1 if index($content, $pinned) >= 0;
|
||
|
|
if (!-f $key_file) {
|
||
|
|
_warn("$key_file not found, leaving the repo gpgkey= unpinned");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
my (@new_lines, $changed);
|
||
|
|
$changed = 0;
|
||
|
|
for my $line (split /\n/, $content, -1) {
|
||
|
|
if (!length $line) {
|
||
|
|
push @new_lines, $line;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
my $stripped = $line;
|
||
|
|
$stripped =~ s/^\s+//;
|
||
|
|
if (index($stripped, 'gpgkey=') == 0) {
|
||
|
|
$line = $pinned;
|
||
|
|
$changed = 1;
|
||
|
|
}
|
||
|
|
push @new_lines, $line;
|
||
|
|
}
|
||
|
|
if (!$changed) {
|
||
|
|
_warn("No gpgkey= line found in $repo_file");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
my $ok = eval { atomic_write($repo_file, join("\n", @new_lines) . "\n"); 1 };
|
||
|
|
if (!$ok) {
|
||
|
|
my $error = $@;
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
_warn("Cannot update $repo_file: $error");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The Brave repo, with the config-manager syntax the installed dnf understands.
|
||
|
|
sub add_brave_repo {
|
||
|
|
my @cmd = defined find_exe('dnf5')
|
||
|
|
? ('dnf', 'config-manager', 'addrepo', "--from-repofile=$BRAVE_REPO_URL")
|
||
|
|
: ('dnf', 'config-manager', '--add-repo', $BRAVE_REPO_URL);
|
||
|
|
my $result = run(\@cmd, timeout => 120, use_sudo => 1);
|
||
|
|
if ($result->{rc} != 0) {
|
||
|
|
_fail('Failed to add the Brave repo (on dnf4 systems this requires '
|
||
|
|
. 'the dnf-plugins-core package)');
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub setup_repos {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my (@added, @skipped);
|
||
|
|
my $repo_name = 'brave-browser';
|
||
|
|
|
||
|
|
_status("Checking $repo_name repo");
|
||
|
|
if (-e $BRAVE_REPO_FILE) {
|
||
|
|
_status_done('already present');
|
||
|
|
if (!$dry_run) {
|
||
|
|
if (!-f $BRAVE_KEY_FILE) {
|
||
|
|
# The repo predates this script's key pinning: fetch, verify and
|
||
|
|
# import the key so gpgkey= can be pinned to a real file.
|
||
|
|
do_import_brave_key();
|
||
|
|
}
|
||
|
|
pin_brave_repo_gpgkey();
|
||
|
|
}
|
||
|
|
push @skipped, $repo_name;
|
||
|
|
}
|
||
|
|
elsif ($dry_run) {
|
||
|
|
_status_done('would verify + import GPG key, would add repo');
|
||
|
|
push @added, $repo_name;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
if (do_import_brave_key() && add_brave_repo()) {
|
||
|
|
pin_brave_repo_gpgkey();
|
||
|
|
_status_done('added');
|
||
|
|
push @added, $repo_name;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { added => \@added, skipped => \@skipped };
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 4. RPM packages
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub install_rpm_packages {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my (@installed, @skipped, @failed, @to_install);
|
||
|
|
|
||
|
|
_status('Checking RPM packages');
|
||
|
|
for my $pkg (@RPM_PACKAGES) {
|
||
|
|
if (rpm_installed($pkg)) { push @skipped, $pkg }
|
||
|
|
else { push @to_install, $pkg }
|
||
|
|
}
|
||
|
|
|
||
|
|
my $already = scalar @skipped;
|
||
|
|
my $missing = scalar @to_install;
|
||
|
|
my $total = scalar @RPM_PACKAGES;
|
||
|
|
_status_done("$already/$total already installed");
|
||
|
|
|
||
|
|
# Flatpak counterparts of the requested RPM packages go first, so the RPM becomes
|
||
|
|
# the single source of truth.
|
||
|
|
my @removed_flatpak;
|
||
|
|
for my $pkg (@RPM_PACKAGES) {
|
||
|
|
my $app_id = remove_conflicting_flatpak($pkg, $dry_run);
|
||
|
|
push @removed_flatpak, $app_id if defined $app_id;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!@to_install) {
|
||
|
|
_ok('All RPM packages already present');
|
||
|
|
return {
|
||
|
|
installed => \@installed,
|
||
|
|
skipped => \@skipped,
|
||
|
|
failed => \@failed,
|
||
|
|
removed_flatpak => \@removed_flatpak,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_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,
|
||
|
|
removed_flatpak => \@removed_flatpak,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_status("Installing $missing package(s)");
|
||
|
|
my $batch = run(['dnf', 'install', '-y', @to_install], timeout => $DNF_TIMEOUT, use_sudo => 1);
|
||
|
|
if ($batch->{rc} == 0) {
|
||
|
|
_status_done();
|
||
|
|
push @installed, @to_install;
|
||
|
|
return {
|
||
|
|
installed => \@installed,
|
||
|
|
skipped => \@skipped,
|
||
|
|
failed => \@failed,
|
||
|
|
removed_flatpak => \@removed_flatpak,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_status_done('batch failed, retrying one by one');
|
||
|
|
for my $pkg (@to_install) {
|
||
|
|
_status("Installing $pkg");
|
||
|
|
my $result = run(['dnf', 'install', '-y', $pkg], timeout => $DNF_TIMEOUT, use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done();
|
||
|
|
push @installed, $pkg;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to install $pkg");
|
||
|
|
push @failed, $pkg;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
installed => \@installed,
|
||
|
|
skipped => \@skipped,
|
||
|
|
failed => \@failed,
|
||
|
|
removed_flatpak => \@removed_flatpak,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 5. Shell PATH exports
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Append export lines to ~/.bashrc unless each is already present. Matching on the
|
||
|
|
# export line content rather than on the surrounding block means a block written by an
|
||
|
|
# upstream installer is recognised too. Returns true when a block was appended.
|
||
|
|
sub add_bashrc_lines {
|
||
|
|
my ($lines, $comment) = @_;
|
||
|
|
my $home = real_home();
|
||
|
|
my $bashrc = "$home/.bashrc";
|
||
|
|
if (!-e $bashrc) {
|
||
|
|
# A missing bashrc must not silently drop the PATH exports: create it owned by
|
||
|
|
# the real user, so the lines land where bash reads them. The handle is
|
||
|
|
# declared before the test, because one declared inside it is scoped to that
|
||
|
|
# block and invisible afterwards.
|
||
|
|
my $create;
|
||
|
|
if (!open($create, '>>', $bashrc)) {
|
||
|
|
_warn("Cannot create $bashrc, PATH setup has to be done manually");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
close($create);
|
||
|
|
chown_user($bashrc);
|
||
|
|
}
|
||
|
|
my $content = slurp($bashrc);
|
||
|
|
my $all_present = 1;
|
||
|
|
for my $line (@$lines) {
|
||
|
|
$all_present = 0 unless index($content, $line) >= 0;
|
||
|
|
}
|
||
|
|
return 0 if $all_present;
|
||
|
|
my $append;
|
||
|
|
if (!open($append, '>>', $bashrc)) {
|
||
|
|
_warn("Cannot append to $bashrc, PATH setup has to be done manually");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
print {$append} "\n# $comment\n" . join("\n", @$lines) . "\n";
|
||
|
|
close($append);
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 6. Go toolchain
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# The architecture in Go's naming, or undef when it is not supported.
|
||
|
|
sub go_arch {
|
||
|
|
my $machine = uname_m();
|
||
|
|
return 'amd64' if $machine eq 'x86_64';
|
||
|
|
return 'arm64' if $machine eq 'aarch64';
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
my $UNAME_M;
|
||
|
|
sub uname_m {
|
||
|
|
return $UNAME_M if defined $UNAME_M;
|
||
|
|
my $result = run(['uname', '-m'], timeout => 5);
|
||
|
|
my $machine = $result->{out};
|
||
|
|
$machine =~ s/^\s+//;
|
||
|
|
$machine =~ s/\s+$//;
|
||
|
|
$UNAME_M = $machine;
|
||
|
|
return $UNAME_M;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The latest stable release for this architecture: (version, filename, sha256).
|
||
|
|
sub latest_go {
|
||
|
|
my $arch = go_arch();
|
||
|
|
if (!defined $arch) {
|
||
|
|
_fail('Unsupported architecture for Go: ' . uname_m());
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
my $result = run(['curl', '-fsSL', $GO_DL_API_URL], timeout => 120);
|
||
|
|
return undef if $result->{rc} != 0;
|
||
|
|
my $releases = eval { json_decode($result->{out}) };
|
||
|
|
return undef if $@ || ref $releases ne 'ARRAY';
|
||
|
|
for my $release (@$releases) {
|
||
|
|
next unless ref $release eq 'HASH' && $release->{stable};
|
||
|
|
my $files = $release->{files};
|
||
|
|
next unless ref $files eq 'ARRAY';
|
||
|
|
for my $artifact (@$files) {
|
||
|
|
next unless ref $artifact eq 'HASH';
|
||
|
|
next unless ($artifact->{os} // '') eq 'linux';
|
||
|
|
next unless ($artifact->{arch} // '') eq $arch;
|
||
|
|
next unless ($artifact->{kind} // '') eq 'archive';
|
||
|
|
return (
|
||
|
|
"$release->{version}",
|
||
|
|
"$artifact->{filename}",
|
||
|
|
lc "$artifact->{sha256}",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The installed Go version (for example go1.27.0), or undef when absent.
|
||
|
|
sub installed_go_version {
|
||
|
|
my $go = find_exe('go');
|
||
|
|
if (!defined $go && -f "$GO_INSTALL_DIR/bin/go") {
|
||
|
|
$go = "$GO_INSTALL_DIR/bin/go";
|
||
|
|
}
|
||
|
|
return undef unless defined $go;
|
||
|
|
my $result = run([$go, 'version']);
|
||
|
|
return undef if $result->{rc} != 0;
|
||
|
|
# The output looks like: "go version go1.27.0 linux/amd64"
|
||
|
|
for my $token (split ' ', $result->{out}) {
|
||
|
|
return $token if index($token, 'go1.') == 0;
|
||
|
|
}
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub setup_go {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (installed => 0, version => '', planned => 0);
|
||
|
|
|
||
|
|
_status('Checking Go');
|
||
|
|
my ($go_version, $filename, $sha256) = latest_go();
|
||
|
|
if (!defined $go_version) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Could not determine the latest Go release from go.dev');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
my $installed = installed_go_version();
|
||
|
|
if (defined $installed && $installed eq $go_version) {
|
||
|
|
_status_done($installed);
|
||
|
|
$info{installed} = 1;
|
||
|
|
$info{version} = $installed;
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would add the Go PATH to ~/.bashrc if missing');
|
||
|
|
}
|
||
|
|
elsif (add_bashrc_lines(\@GO_BASHRC_LINES, 'Go')) {
|
||
|
|
_info('Added Go PATH to ~/.bashrc');
|
||
|
|
}
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done(defined $installed
|
||
|
|
? "$installed installed (latest: $go_version)"
|
||
|
|
: 'not installed');
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would remove RPM package: golang') if rpm_installed('golang');
|
||
|
|
_info("Would download $filename from go.dev, verify SHA-256, "
|
||
|
|
. "and install to $GO_INSTALL_DIR");
|
||
|
|
$info{planned} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (rpm_installed('golang')) {
|
||
|
|
_warn('Removing the golang RPM: the official toolchain becomes the only Go');
|
||
|
|
my $removed = run(['dnf', 'remove', '-y', 'golang'], timeout => $DNF_TIMEOUT, use_sudo => 1);
|
||
|
|
if ($removed->{rc} != 0) {
|
||
|
|
_fail('Failed to remove the golang RPM');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
_status("Installing $go_version");
|
||
|
|
my $tmp = make_temp_dir();
|
||
|
|
if (!defined $tmp) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Could not create a temporary directory');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $tarball = "$tmp/$filename";
|
||
|
|
if (!download("$GO_TARBALL_BASE_URL$filename", $tarball, timeout => 900)) {
|
||
|
|
_status_done('download failed');
|
||
|
|
_fail("Failed to download $filename from go.dev");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $actual = sha256_file($tarball);
|
||
|
|
if ($actual ne $sha256) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("SHA-256 mismatch for $filename: expected $sha256, got $actual");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_info('SHA-256 checksum verified');
|
||
|
|
# The official install procedure replaces the whole directory: removing it first
|
||
|
|
# stops old binaries from shadowing the new tree.
|
||
|
|
my $wipe = run(['rm', '-rf', $GO_INSTALL_DIR], use_sudo => 1);
|
||
|
|
if ($wipe->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to remove the existing $GO_INSTALL_DIR");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $extract = run(['tar', '-C', '/usr/local', '-xzf', $tarball], timeout => 600, use_sudo => 1);
|
||
|
|
remove_tree($tmp);
|
||
|
|
if ($extract->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to extract $filename to /usr/local");
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (add_bashrc_lines(\@GO_BASHRC_LINES, 'Go')) {
|
||
|
|
_info('Added Go PATH to ~/.bashrc');
|
||
|
|
}
|
||
|
|
my $version = installed_go_version();
|
||
|
|
_status_done(defined $version ? $version : 'installed');
|
||
|
|
if (defined $version) {
|
||
|
|
$info{installed} = 1;
|
||
|
|
$info{version} = $version;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail('Go binary not usable after installation');
|
||
|
|
}
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 7. Rust toolchain
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
sub setup_rust {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (installed => 0, version => '', planned => 0);
|
||
|
|
|
||
|
|
_status('Checking Rust');
|
||
|
|
my $rustc = tool_path('rustc', '.cargo/bin/rustc');
|
||
|
|
if (defined $rustc) {
|
||
|
|
$info{version} = probe_version([$rustc, '--version']);
|
||
|
|
_status_done($info{version});
|
||
|
|
$info{installed} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status_done('not installed');
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would run: rustup-init -y');
|
||
|
|
$info{planned} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
my $rustup_init = tool_path('rustup-init');
|
||
|
|
if (!defined $rustup_init) {
|
||
|
|
_fail("rustup-init not found, install the 'rustup' RPM first "
|
||
|
|
. '(or do not pass --skip-rpm)');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Installing Rust toolchain');
|
||
|
|
my $result = run_as_user([$rustup_init, '-y'], timeout => 300);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
$info{version} = probe_version([real_home() . '/.cargo/bin/rustc', '--version']);
|
||
|
|
_status_done($info{version});
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Rust installation returned non-zero exit code');
|
||
|
|
}
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 8. JetBrains IDEs
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# The downloads-API key for this machine's architecture.
|
||
|
|
sub jetbrains_download_key {
|
||
|
|
return uname_m() eq 'aarch64' ? 'linuxARM64' : 'linux';
|
||
|
|
}
|
||
|
|
|
||
|
|
# The latest release entry for a product code, from the releases API.
|
||
|
|
sub latest_jetbrains {
|
||
|
|
my ($code) = @_;
|
||
|
|
my $url = "$JETBRAINS_RELEASES_URL?code=$code&latest=true&type=release";
|
||
|
|
my $result = run(['curl', '-fsSL', $url], timeout => 120);
|
||
|
|
return undef if $result->{rc} != 0;
|
||
|
|
my $releases = eval { json_decode($result->{out}) };
|
||
|
|
return undef if $@ || ref $releases ne 'HASH';
|
||
|
|
my $entries = $releases->{$code};
|
||
|
|
return undef unless ref $entries eq 'ARRAY' && @$entries;
|
||
|
|
return $entries->[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
# Existing installation directories for an IDE under /opt.
|
||
|
|
sub jetbrains_install_dirs {
|
||
|
|
my ($name) = @_;
|
||
|
|
return () unless -d $JETBRAINS_INSTALL_ROOT;
|
||
|
|
my @entries;
|
||
|
|
if (opendir(my $dh, $JETBRAINS_INSTALL_ROOT)) {
|
||
|
|
@entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
|
||
|
|
closedir($dh);
|
||
|
|
}
|
||
|
|
my @dirs;
|
||
|
|
for my $entry (@entries) {
|
||
|
|
next unless index($entry, "$name-") == 0;
|
||
|
|
push @dirs, "$JETBRAINS_INSTALL_ROOT/$entry" if -d "$JETBRAINS_INSTALL_ROOT/$entry";
|
||
|
|
}
|
||
|
|
return @dirs;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The icon file shipped in the IDE's bin/ directory, if there is one.
|
||
|
|
sub jetbrains_icon {
|
||
|
|
my ($install_dir, $name) = @_;
|
||
|
|
my $bin_dir = "$install_dir/bin";
|
||
|
|
my $lower = lc $name;
|
||
|
|
for my $candidate ("$lower.svg", "$lower.png") {
|
||
|
|
my $path = "$bin_dir/$candidate";
|
||
|
|
return $path if -f $path;
|
||
|
|
}
|
||
|
|
if (opendir(my $dh, $bin_dir)) {
|
||
|
|
my @entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
|
||
|
|
closedir($dh);
|
||
|
|
for my $entry (@entries) {
|
||
|
|
return "$bin_dir/$entry" if $entry =~ /\.svg$/;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return undef;
|
||
|
|
}
|
||
|
|
|
||
|
|
# A menu entry pointing at the verified launcher path.
|
||
|
|
sub write_jetbrains_desktop_entry {
|
||
|
|
my ($name, $install_dir, $launcher, $icon, $desktop_dir) = @_;
|
||
|
|
$desktop_dir //= $JETBRAINS_DESKTOP_DIR;
|
||
|
|
my $lower = lc $name;
|
||
|
|
my $path = "$desktop_dir/jetbrains-$lower.desktop";
|
||
|
|
my @lines = (
|
||
|
|
'[Desktop Entry]',
|
||
|
|
'Version=1.0',
|
||
|
|
'Type=Application',
|
||
|
|
"Name=$name",
|
||
|
|
"Exec=\"$launcher\" %f",
|
||
|
|
);
|
||
|
|
push @lines, "Icon=$icon" if defined $icon;
|
||
|
|
push @lines,
|
||
|
|
"Comment=$name by JetBrains",
|
||
|
|
'Categories=Development;IDE;',
|
||
|
|
'Terminal=false',
|
||
|
|
"StartupWMClass=jetbrains-$lower",
|
||
|
|
'StartupNotify=true';
|
||
|
|
if (!make_dirs($desktop_dir)) {
|
||
|
|
_warn("Could not write $path");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
my $ok = eval { atomic_write($path, join("\n", @lines) . "\n"); 1 };
|
||
|
|
if (!$ok) {
|
||
|
|
my $error = $@;
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
_warn("Could not write $path: $error");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
chmod(0644, $path);
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# The command-line symlink and the menu entry, ensured on every run: an earlier
|
||
|
|
# interrupted run between the directory move and the links is healed by the next
|
||
|
|
# one. The directories are parameters so the links can be exercised away from
|
||
|
|
# /usr/local.
|
||
|
|
sub jetbrains_links {
|
||
|
|
my ($name, $install_dir, $bin_dir, $desktop_dir) = @_;
|
||
|
|
$bin_dir //= $JETBRAINS_BIN_DIR;
|
||
|
|
$desktop_dir //= $JETBRAINS_DESKTOP_DIR;
|
||
|
|
my $lower = lc $name;
|
||
|
|
my $launcher = "$install_dir/bin/$lower.sh";
|
||
|
|
if (!-f $launcher) {
|
||
|
|
_fail("Launcher not found: $launcher");
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
my $bin_link = "$bin_dir/$lower";
|
||
|
|
if (-l $bin_link) {
|
||
|
|
my $target = readlink($bin_link) // '';
|
||
|
|
if ($target ne $launcher) {
|
||
|
|
unlink($bin_link)
|
||
|
|
and symlink($launcher, $bin_link)
|
||
|
|
or _warn("Could not repoint the $bin_link symlink: $!");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elsif (!-e $bin_link) {
|
||
|
|
symlink($launcher, $bin_link)
|
||
|
|
or _warn("Could not create the $bin_link symlink: $!");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_warn("$bin_link already exists and is not a symlink, left in place");
|
||
|
|
}
|
||
|
|
|
||
|
|
write_jetbrains_desktop_entry($name, $install_dir, $launcher,
|
||
|
|
jetbrains_icon($install_dir, $name), $desktop_dir);
|
||
|
|
return 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
# Install the latest release of one IDE system-wide.
|
||
|
|
sub setup_jetbrains_ide {
|
||
|
|
my ($code, $name, $dry_run) = @_;
|
||
|
|
my %info = (installed => 0, version => '', planned => 0);
|
||
|
|
|
||
|
|
_status("Checking $name");
|
||
|
|
my $release = latest_jetbrains($code);
|
||
|
|
if (!defined $release) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Could not determine the latest $name release from JetBrains");
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $version = "$release->{version}";
|
||
|
|
my $downloads = ref $release->{downloads} eq 'HASH' ? $release->{downloads} : {};
|
||
|
|
my $key = jetbrains_download_key();
|
||
|
|
my $download = ref $downloads->{$key} eq 'HASH' ? $downloads->{$key} : {};
|
||
|
|
my $link = "$download->{link}";
|
||
|
|
my $checksum_link = "$download->{checksumLink}";
|
||
|
|
if (!length($release->{version} // '') || !length($download->{link} // '')
|
||
|
|
|| !length($download->{checksumLink} // '')) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Incomplete $name release metadata from JetBrains");
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
my $install_dir = "$JETBRAINS_INSTALL_ROOT/$name-$version";
|
||
|
|
my @existing = jetbrains_install_dirs($name);
|
||
|
|
if (grep { $_ eq $install_dir } @existing) {
|
||
|
|
# Already at the latest release: the links are re-checked so an earlier
|
||
|
|
# interrupted run does not leave the IDE without a launcher.
|
||
|
|
if (jetbrains_links($name, $install_dir)) {
|
||
|
|
_status_done($version);
|
||
|
|
$info{installed} = 1;
|
||
|
|
$info{version} = $version;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('broken install, the launcher is missing');
|
||
|
|
}
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done(@existing ? "older build present (latest: $version)" : 'not installed');
|
||
|
|
|
||
|
|
if ($dry_run) {
|
||
|
|
my $tar_name = $link;
|
||
|
|
$tar_name =~ s{.*/}{};
|
||
|
|
_info("Would download $tar_name, verify SHA-256, and install to $install_dir");
|
||
|
|
$info{planned} = 1;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status("Installing $name $version");
|
||
|
|
my $tar_name = $link;
|
||
|
|
$tar_name =~ s{.*/}{};
|
||
|
|
my $tmp = make_temp_dir();
|
||
|
|
if (!defined $tmp) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Could not create a temporary directory');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $tarball = "$tmp/$tar_name";
|
||
|
|
if (!download($link, $tarball, timeout => 1800)) {
|
||
|
|
_status_done('download failed');
|
||
|
|
_fail("Failed to download $tar_name");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $sums_path = "$tmp/checksums.sha256";
|
||
|
|
if (!download($checksum_link, $sums_path, timeout => 60)) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to download the $name SHA-256 checksum");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $expected = sha256_from_sums($sums_path, $tar_name);
|
||
|
|
if (!defined $expected) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("No checksum for $tar_name in the published SHA-256 file");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $actual = sha256_file($tarball);
|
||
|
|
if ($actual ne $expected) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("SHA-256 mismatch for $tar_name: expected $expected, got $actual");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_info('SHA-256 checksum verified');
|
||
|
|
|
||
|
|
# Extract into a staging directory first: a failure then never leaves a
|
||
|
|
# half-installed IDE in /opt, and the top-level directory is moved to its
|
||
|
|
# canonical name afterwards.
|
||
|
|
make_dirs($JETBRAINS_INSTALL_ROOT);
|
||
|
|
my $staging = make_temp_dir(dir => $JETBRAINS_INSTALL_ROOT, prefix => ".$name-stage-");
|
||
|
|
if (!defined $staging) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Could not create a staging directory in $JETBRAINS_INSTALL_ROOT");
|
||
|
|
remove_tree($tmp);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my $extract = run(['tar', '-C', $staging, '-xzf', $tarball], timeout => 1200, use_sudo => 1);
|
||
|
|
remove_tree($tmp);
|
||
|
|
if ($extract->{rc} != 0) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to extract $tar_name");
|
||
|
|
remove_tree($staging);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
my @entries;
|
||
|
|
if (opendir(my $dh, $staging)) {
|
||
|
|
@entries = sort grep { $_ ne '.' && $_ ne '..' } readdir($dh);
|
||
|
|
closedir($dh);
|
||
|
|
}
|
||
|
|
if (@entries != 1 || !-d "$staging/$entries[0]") {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Unexpected archive layout in $tar_name: " . join(', ', @entries));
|
||
|
|
remove_tree($staging);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
remove_tree($install_dir) if -e $install_dir;
|
||
|
|
if (!rename("$staging/$entries[0]", $install_dir)) {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Could not move the extracted $name into place: $!");
|
||
|
|
remove_tree($staging);
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
remove_tree($staging);
|
||
|
|
|
||
|
|
# Older versions go, so the latest release is the only one in /opt.
|
||
|
|
for my $old_dir (@existing) {
|
||
|
|
next if $old_dir eq $install_dir;
|
||
|
|
next unless -d $old_dir;
|
||
|
|
_warn("Removing previous $name install: $old_dir");
|
||
|
|
remove_tree($old_dir);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!jetbrains_links($name, $install_dir)) {
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status_done();
|
||
|
|
$info{installed} = 1;
|
||
|
|
$info{version} = $version;
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub setup_jetbrains {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %results;
|
||
|
|
for my $code (@JETBRAINS_ORDER) {
|
||
|
|
$results{$code} = setup_jetbrains_ide($code, $JETBRAINS_IDES{$code}, $dry_run);
|
||
|
|
}
|
||
|
|
return \%results;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 9. Flatpak apps
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Names of the configured system-wide remotes, matched exactly rather than by
|
||
|
|
# substring.
|
||
|
|
sub flatpak_remote_names {
|
||
|
|
my $result = run(['flatpak', 'remotes']);
|
||
|
|
my %names;
|
||
|
|
for my $line (split /\n/, $result->{out}) {
|
||
|
|
my @tokens = split ' ', $line;
|
||
|
|
next unless @tokens;
|
||
|
|
next if $tokens[0] eq 'Name'; # the table header
|
||
|
|
$names{ $tokens[0] } = 1;
|
||
|
|
}
|
||
|
|
return %names;
|
||
|
|
}
|
||
|
|
|
||
|
|
sub setup_flatpak {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my (@installed, @skipped, @failed, @removed_rpm);
|
||
|
|
my $remote_added = 0;
|
||
|
|
|
||
|
|
_status('Checking Flathub remote');
|
||
|
|
my %remotes = flatpak_remote_names();
|
||
|
|
if ($remotes{flathub}) {
|
||
|
|
_status_done('already present');
|
||
|
|
}
|
||
|
|
elsif ($dry_run) {
|
||
|
|
_status_done('would add');
|
||
|
|
$remote_added = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $result = run(
|
||
|
|
['flatpak', 'remote-add', '--if-not-exists', 'flathub',
|
||
|
|
'https://flathub.org/repo/flathub.flatpakrepo'],
|
||
|
|
timeout => 120, use_sudo => 1,
|
||
|
|
);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done('added');
|
||
|
|
$remote_added = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail('Failed to add Flathub remote');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for my $app (@FLATPAK_APPS) {
|
||
|
|
my $rpm_name = remove_conflicting_rpm($app, $dry_run);
|
||
|
|
push @removed_rpm, $rpm_name if defined $rpm_name;
|
||
|
|
_status("Checking $app");
|
||
|
|
if (have_flatpak($app)) {
|
||
|
|
_status_done('already installed');
|
||
|
|
push @skipped, $app;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would install');
|
||
|
|
push @installed, $app;
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
my $result = run(['flatpak', 'install', '-y', 'flathub', $app],
|
||
|
|
timeout => 300, use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done('installed');
|
||
|
|
push @installed, $app;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
_fail("Failed to install $app");
|
||
|
|
push @failed, $app;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
remote_added => $remote_added,
|
||
|
|
installed => \@installed,
|
||
|
|
skipped => \@skipped,
|
||
|
|
failed => \@failed,
|
||
|
|
removed_rpm => \@removed_rpm,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 10. Firewall
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Ensure firewalld is installed, enabled and running. Unlike the server setup this
|
||
|
|
# does NOT change the default zone: the workstation default is left as it is.
|
||
|
|
sub setup_firewall {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (installed => 0, enabled => 0, running => 0);
|
||
|
|
|
||
|
|
_status('Checking firewalld');
|
||
|
|
if (!rpm_installed('firewalld')) {
|
||
|
|
_status_done('not installed');
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would install: firewalld');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $result = run(['dnf', 'install', '-y', 'firewalld'],
|
||
|
|
timeout => $DNF_TIMEOUT, use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_ok('Installed firewalld');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail('Failed to install firewalld');
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('installed');
|
||
|
|
$info{installed} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Enabling firewalld service');
|
||
|
|
my $enabled = run(['systemctl', 'is-enabled', 'firewalld.service']);
|
||
|
|
if ($enabled->{rc} != 0) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would enable');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $result = run(['systemctl', 'enable', 'firewalld.service'], use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done('enabled');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
my $error = $result->{err};
|
||
|
|
$error =~ s/^\s+//;
|
||
|
|
$error =~ s/\s+$//;
|
||
|
|
_fail("Failed to enable firewalld: $error");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already enabled');
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Starting firewalld service');
|
||
|
|
my $active = run(['systemctl', 'is-active', 'firewalld.service']);
|
||
|
|
if ($active->{rc} != 0) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_status_done('would start');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
my $result = run(['systemctl', 'start', 'firewalld.service'], use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done('started');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
my $error = $result->{err};
|
||
|
|
$error =~ s/^\s+//;
|
||
|
|
$error =~ s/\s+$//;
|
||
|
|
_fail("Failed to start firewalld: $error");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already running');
|
||
|
|
}
|
||
|
|
|
||
|
|
# Reflect the real probed state, never an assumed success.
|
||
|
|
if (!$dry_run) {
|
||
|
|
$info{enabled} = run(['systemctl', 'is-enabled', 'firewalld.service'])->{rc} == 0 ? 1 : 0;
|
||
|
|
$info{running} = run(['systemctl', 'is-active', 'firewalld.service'])->{rc} == 0 ? 1 : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 11. SELinux
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# Ensure SELinux is enforcing. A disabled SELinux cannot be switched at runtime, so
|
||
|
|
# only the configuration is fixed and a reboot warning is printed.
|
||
|
|
sub setup_selinux {
|
||
|
|
my ($dry_run) = @_;
|
||
|
|
my %info = (mode_changed => 0, config_changed => 0, current_mode => 'unknown',
|
||
|
|
reboot_required => 0);
|
||
|
|
|
||
|
|
_status('Checking SELinux mode');
|
||
|
|
my $mode_result = run(['getenforce']);
|
||
|
|
my $current_mode = $mode_result->{out};
|
||
|
|
$current_mode =~ s/^\s+//;
|
||
|
|
$current_mode =~ s/\s+$//;
|
||
|
|
$current_mode = 'unknown' unless length $current_mode;
|
||
|
|
$info{current_mode} = $current_mode;
|
||
|
|
_status_done($current_mode);
|
||
|
|
|
||
|
|
if ($current_mode eq 'Permissive') {
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Would set SELinux to enforcing mode');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status('Setting SELinux to enforcing');
|
||
|
|
my $result = run(['setenforce', '1'], use_sudo => 1);
|
||
|
|
if ($result->{rc} == 0) {
|
||
|
|
_status_done();
|
||
|
|
$info{mode_changed} = 1;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('failed');
|
||
|
|
my $error = $result->{err};
|
||
|
|
$error =~ s/^\s+//;
|
||
|
|
$error =~ s/\s+$//;
|
||
|
|
_fail("setenforce 1 failed: $error");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elsif ($current_mode eq 'Disabled') {
|
||
|
|
_warn('SELinux is disabled, it cannot be enabled at runtime. '
|
||
|
|
. 'Setting SELINUX=enforcing in the config; a REBOOT is required.');
|
||
|
|
$info{reboot_required} = 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
_status('Checking /etc/selinux/config');
|
||
|
|
my $read_ok = open(my $config_fh, '<', '/etc/selinux/config');
|
||
|
|
my $read_error = $read_ok ? '' : os_error_text('/etc/selinux/config');
|
||
|
|
my $content;
|
||
|
|
if ($read_ok) {
|
||
|
|
$content = do { local $/ = undef; <$config_fh> };
|
||
|
|
close($config_fh);
|
||
|
|
}
|
||
|
|
if (!$read_ok || !defined $content) {
|
||
|
|
_status_done('error');
|
||
|
|
_warn("Cannot read/write /etc/selinux/config: $read_error");
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
my @lines = split /\n/, $content, -1;
|
||
|
|
pop @lines if @lines && $lines[-1] eq '';
|
||
|
|
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, $found);
|
||
|
|
$found = 0;
|
||
|
|
for my $line (@lines) {
|
||
|
|
my $stripped = $line;
|
||
|
|
$stripped =~ s/^\s+//;
|
||
|
|
$stripped =~ s/\s+$//;
|
||
|
|
if (index($stripped, 'SELINUX=') == 0 && index($stripped, '#') != 0) {
|
||
|
|
if (!$found) {
|
||
|
|
push @new_lines, 'SELINUX=enforcing';
|
||
|
|
$found = 1;
|
||
|
|
}
|
||
|
|
# Any duplicate active SELINUX= line is dropped.
|
||
|
|
next;
|
||
|
|
}
|
||
|
|
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) {
|
||
|
|
my $error = $@;
|
||
|
|
$error =~ s/\s+\z//;
|
||
|
|
_status_done('error');
|
||
|
|
_warn("Cannot read/write /etc/selinux/config: $error");
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
_status_done('updated to enforcing');
|
||
|
|
$info{config_changed} = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_status_done('already enforcing');
|
||
|
|
}
|
||
|
|
|
||
|
|
return \%info;
|
||
|
|
}
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Summary
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
# In dry-run mode nothing is phrased as accomplished: only would-be actions.
|
||
|
|
sub print_summary {
|
||
|
|
my ($os_display, $version_id, $results, $warnings, $elapsed, $dry_run) = @_;
|
||
|
|
my $bar = "═" x 60;
|
||
|
|
|
||
|
|
print STDERR "\n${BOLD}══ Setup Summary ══$RESET\n";
|
||
|
|
print STDERR " OS: $os_display $version_id\n";
|
||
|
|
|
||
|
|
my $update = $results->{update} // {};
|
||
|
|
if ($update->{error}) {
|
||
|
|
_fail('System update failed');
|
||
|
|
}
|
||
|
|
elsif ($update->{would_update}) {
|
||
|
|
_info('Would apply system updates');
|
||
|
|
}
|
||
|
|
elsif ($update->{updated}) {
|
||
|
|
_ok('System updated');
|
||
|
|
}
|
||
|
|
elsif ($update->{skipped}) {
|
||
|
|
_info('System already up to date');
|
||
|
|
}
|
||
|
|
elsif (%$update) {
|
||
|
|
_info('System update skipped');
|
||
|
|
}
|
||
|
|
|
||
|
|
my $remove = $results->{remove} // {};
|
||
|
|
if (%$remove) {
|
||
|
|
my $removed = scalar @{ $remove->{removed} // [] };
|
||
|
|
my $skipped = scalar @{ $remove->{skipped} // [] };
|
||
|
|
if ($dry_run) {
|
||
|
|
_info("Remove: would remove $removed, $skipped already absent");
|
||
|
|
}
|
||
|
|
elsif ($removed) {
|
||
|
|
_ok("Removed: $removed package(s), $skipped already absent");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info("Remove: $skipped package(s) already absent");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
my $repos = $results->{repos} // {};
|
||
|
|
if (%$repos) {
|
||
|
|
my $added = scalar @{ $repos->{added} // [] };
|
||
|
|
my $skipped = scalar @{ $repos->{skipped} // [] };
|
||
|
|
if ($dry_run) {
|
||
|
|
_info("Repos: would add $added, $skipped already present");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_ok("Repos: $added added, $skipped already present");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
my $rpm = $results->{rpm} // {};
|
||
|
|
if (%$rpm) {
|
||
|
|
my $installed = scalar @{ $rpm->{installed} // [] };
|
||
|
|
my $skipped = scalar @{ $rpm->{skipped} // [] };
|
||
|
|
my $failed = scalar @{ $rpm->{failed} // [] };
|
||
|
|
if ($dry_run) {
|
||
|
|
_info("RPM packages: would install $installed, $skipped already present");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_ok("RPM packages: $skipped already present, $installed installed");
|
||
|
|
}
|
||
|
|
_fail(" $failed package(s) failed to install") if $failed;
|
||
|
|
my $removed = scalar @{ $rpm->{removed_flatpak} // [] };
|
||
|
|
if ($removed) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_info(" Would uninstall $removed duplicate Flatpak app(s)");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_ok(" Uninstalled $removed duplicate Flatpak app(s)");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
# Tools fetched by curl or packaged
|
||
|
|
for my $item (['go', 'Go'], ['rust', 'Rust']) {
|
||
|
|
my ($section, $label) = @$item;
|
||
|
|
my $tool = $results->{$section} // {};
|
||
|
|
next unless %$tool;
|
||
|
|
if ($tool->{version}) {
|
||
|
|
_ok("$label: $tool->{version}");
|
||
|
|
}
|
||
|
|
elsif ($tool->{planned}) {
|
||
|
|
_info("$label: would be installed");
|
||
|
|
}
|
||
|
|
elsif ($tool->{installed}) {
|
||
|
|
_ok("$label: installed");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info("$label: not installed");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
my $jetbrains = $results->{jetbrains} // {};
|
||
|
|
for my $code (@JETBRAINS_ORDER) {
|
||
|
|
my $tool = $jetbrains->{$code};
|
||
|
|
next unless defined $tool;
|
||
|
|
my $name = $JETBRAINS_IDES{$code};
|
||
|
|
if ($tool->{version}) {
|
||
|
|
_ok("$name: $tool->{version}");
|
||
|
|
}
|
||
|
|
elsif ($tool->{planned}) {
|
||
|
|
_info("$name: would be installed");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail("$name: not installed");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
my $flatpak = $results->{flatpak} // {};
|
||
|
|
if (%$flatpak) {
|
||
|
|
my $installed = scalar @{ $flatpak->{installed} // [] };
|
||
|
|
my $skipped = scalar @{ $flatpak->{skipped} // [] };
|
||
|
|
my $failed = scalar @{ $flatpak->{failed} // [] };
|
||
|
|
if ($dry_run) {
|
||
|
|
_info("Flatpak: would install $installed, $skipped already present");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_ok("Flatpak: $skipped already present, $installed installed");
|
||
|
|
}
|
||
|
|
_fail(" $failed app(s) failed to install") if $failed;
|
||
|
|
my $removed = scalar @{ $flatpak->{removed_rpm} // [] };
|
||
|
|
if ($removed) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_info(" Would remove $removed duplicate RPM package(s)");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_ok(" Removed $removed duplicate RPM package(s)");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
my $firewall = $results->{firewall} // {};
|
||
|
|
if (%$firewall) {
|
||
|
|
if ($dry_run) {
|
||
|
|
_info('Firewall: would ensure firewalld is installed, enabled and running');
|
||
|
|
}
|
||
|
|
elsif ($firewall->{running}) {
|
||
|
|
_ok('Firewall: installed & running');
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_fail('Firewall: not running');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
my $selinux = $results->{selinux} // {};
|
||
|
|
if (%$selinux) {
|
||
|
|
my $mode = $selinux->{current_mode} // '?';
|
||
|
|
if ($selinux->{reboot_required}) {
|
||
|
|
_warn("SELinux: mode=$mode, config set to enforcing, REBOOT required");
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_ok("SELinux: mode=$mode");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (@$warnings) {
|
||
|
|
print STDERR "\n";
|
||
|
|
_warn($_) for @$warnings;
|
||
|
|
}
|
||
|
|
|
||
|
|
print STDERR "\n${BOLD}${bar}$RESET\n";
|
||
|
|
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{.*/}{};
|
||
|
|
my $systems = join(' and ', map { $SUPPORTED_OS{$_} } @SUPPORTED_ORDER);
|
||
|
|
return <<"USAGE";
|
||
|
|
Usage: $name [options]
|
||
|
|
|
||
|
|
Idempotent workstation setup for $systems
|
||
|
|
|
||
|
|
Options:
|
||
|
|
--dry-run Print what would be done without making changes
|
||
|
|
--skip-update Skip system update
|
||
|
|
--skip-rpm Skip RPM package installation
|
||
|
|
--skip-flatpak Skip Flatpak apps
|
||
|
|
--skip-repos Skip adding third-party repos
|
||
|
|
--skip-remove Skip removing pre-installed apps
|
||
|
|
--skip-go Skip Go toolchain installation
|
||
|
|
--skip-rust Skip Rust toolchain installation
|
||
|
|
--skip-jetbrains Skip JetBrains IDE installation
|
||
|
|
--skip-firewall Skip firewall setup
|
||
|
|
--skip-selinux Skip SELinux setup
|
||
|
|
--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_rpm => 0,
|
||
|
|
skip_flatpak => 0,
|
||
|
|
skip_repos => 0,
|
||
|
|
skip_remove => 0,
|
||
|
|
skip_go => 0,
|
||
|
|
skip_rust => 0,
|
||
|
|
skip_jetbrains => 0,
|
||
|
|
skip_firewall => 0,
|
||
|
|
skip_selinux => 0,
|
||
|
|
);
|
||
|
|
my %flag_for = (
|
||
|
|
'--dry-run' => 'dry_run',
|
||
|
|
'--skip-update' => 'skip_update',
|
||
|
|
'--skip-rpm' => 'skip_rpm',
|
||
|
|
'--skip-flatpak' => 'skip_flatpak',
|
||
|
|
'--skip-repos' => 'skip_repos',
|
||
|
|
'--skip-remove' => 'skip_remove',
|
||
|
|
'--skip-go' => 'skip_go',
|
||
|
|
'--skip-rust' => 'skip_rust',
|
||
|
|
'--skip-jetbrains' => 'skip_jetbrains',
|
||
|
|
'--skip-firewall' => 'skip_firewall',
|
||
|
|
'--skip-selinux' => 'skip_selinux',
|
||
|
|
);
|
||
|
|
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 --help and --version work anywhere.
|
||
|
|
my %opt = parse_args();
|
||
|
|
|
||
|
|
my ($os_id, $os_display, $version_id) = detect_os();
|
||
|
|
|
||
|
|
my $bar = "═" x 60;
|
||
|
|
print STDERR "\n${BOLD}${bar}$RESET\n";
|
||
|
|
print STDERR "${BOLD} Workstation 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});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('System update: skipped (--skip-update)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 2. Remove pre-installed apps
|
||
|
|
print STDERR "\n${BOLD}── Remove Pre-installed Apps ──$RESET\n";
|
||
|
|
if (!$opt{skip_remove}) {
|
||
|
|
$results{remove} = remove_packages($opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Remove apps: skipped (--skip-remove)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 3. Third-party repos
|
||
|
|
print STDERR "\n${BOLD}── Repositories ──$RESET\n";
|
||
|
|
if (!$opt{skip_repos}) {
|
||
|
|
$results{repos} = setup_repos($opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Repos: skipped (--skip-repos)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 4. RPM packages
|
||
|
|
print STDERR "\n${BOLD}── RPM Packages ──$RESET\n";
|
||
|
|
if (!$opt{skip_rpm}) {
|
||
|
|
$results{rpm} = install_rpm_packages($opt{dry_run});
|
||
|
|
if (@{ $results{rpm}{failed} }) {
|
||
|
|
push @warnings, 'Some RPM packages failed to install: '
|
||
|
|
. join(', ', @{ $results{rpm}{failed} });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('RPM packages: skipped (--skip-rpm)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 5. Go toolchain
|
||
|
|
print STDERR "\n${BOLD}── Go Toolchain ──$RESET\n";
|
||
|
|
if (!$opt{skip_go}) {
|
||
|
|
$results{go} = setup_go($opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Go: skipped (--skip-go)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 6. Rust toolchain
|
||
|
|
print STDERR "\n${BOLD}── Rust Toolchain ──$RESET\n";
|
||
|
|
if (!$opt{skip_rust}) {
|
||
|
|
$results{rust} = setup_rust($opt{dry_run});
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Rust: skipped (--skip-rust)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 7. JetBrains IDEs
|
||
|
|
print STDERR "\n${BOLD}── JetBrains IDEs ──$RESET\n";
|
||
|
|
if (!$opt{skip_jetbrains}) {
|
||
|
|
$results{jetbrains} = setup_jetbrains($opt{dry_run});
|
||
|
|
my @failed_ides;
|
||
|
|
for my $code (@JETBRAINS_ORDER) {
|
||
|
|
my $tool = $results{jetbrains}{$code};
|
||
|
|
next unless defined $tool;
|
||
|
|
push @failed_ides, $JETBRAINS_IDES{$code}
|
||
|
|
unless $tool->{installed} || $tool->{planned};
|
||
|
|
}
|
||
|
|
push @warnings, 'Some JetBrains IDEs failed to install: ' . join(', ', @failed_ides)
|
||
|
|
if @failed_ides;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('JetBrains IDEs: skipped (--skip-jetbrains)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 8. Flatpak apps
|
||
|
|
print STDERR "\n${BOLD}── Flatpak Apps ──$RESET\n";
|
||
|
|
if (!$opt{skip_flatpak}) {
|
||
|
|
$results{flatpak} = setup_flatpak($opt{dry_run});
|
||
|
|
if (@{ $results{flatpak}{failed} }) {
|
||
|
|
push @warnings, 'Some Flatpak apps failed to install: '
|
||
|
|
. join(', ', @{ $results{flatpak}{failed} });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
_info('Flatpak: skipped (--skip-flatpak)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 9. 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)');
|
||
|
|
}
|
||
|
|
|
||
|
|
# 10. 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)');
|
||
|
|
}
|
||
|
|
|
||
|
|
my $elapsed = now() - $start_time;
|
||
|
|
print_summary($os_display, $version_id, \%results, \@warnings, $elapsed, $opt{dry_run});
|
||
|
|
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;
|