#!/usr/bin/env perl # Copyright (c) 2026 Petr Balvín (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;