#!/usr/bin/env perl # patch - Apply one or more patches to Webmin core or its modules from # GitHub or a local file use strict; use warnings; use 5.010; use Getopt::Long qw(:config permute pass_through); use Pod::Usage; use File::Basename; use Cwd qw(cwd); my %opt; GetOptions( 'help|h' => \$opt{'help'}, 'config|c=s' => \$opt{'config'}, ); pod2usage(0) if ($opt{'help'}); # Get Webmin path my $path = cwd; my $lib = "web-lib-funcs.pl"; if (!-r "$path/$lib") { $path = dirname(dirname($0)); if (!-r "$path/$lib") { $path = $path = Cwd::realpath('..'); } } # Init core my $config_dir = $opt{'config'} || '/etc/webmin'; $ENV{'WEBMIN_CONFIG'} = $config_dir; push(@INC, $path); eval 'use WebminCore'; init_config(); # Check if curl is installed if (!has_command('curl')) { print "\"curl\" command is not installed\n"; exit 1; } # Check if git is installed if (!has_command('patch')) { if (!has_command('git')) { print "Neither \"patch\" nor \"git\" commands are installed\n"; exit 1; } } # Get patch URLs or files my @patches = @ARGV; # Params check if (!@patches) { pod2usage(0); exit 1; } # Apply all patches in the given order, stopping at the first failure my $orig_cwd = cwd(); my $applied = 0; my $failed = 0; foreach my $patch (@patches) { # Show which patch is being applied if there are multiple print "Applying $patch ..\n" if (@patches > 1); # Always start from the original directory, as applying a patch # may change it chdir($orig_cwd); if (!apply_patch($patch)) { $failed = 1; last; } $applied++; } # Restart Webmin once, if any patches were applied, so that changes to # any file, including miniserv.pl itself, take effect restart_miniserv() if ($applied); # Report if some patches were not applied if ($failed) { print "Stopped: $applied of ".scalar(@patches)." patches applied\n" if (@patches > 1); exit 1; } exit 0; # apply_patch(patch) # Apply a single patch given as URL or local file, printing the outcome # and returning 1 on success or 0 on failure sub apply_patch { my ($patch) = @_; # Patch check if ($patch !~ /^https?:\/\//) { if (!-r $patch) { print "Patch file $patch doesn't exist\n"; return 0; } } elsif ($patch =~ /^https?:\/\/(github|gitlab)\.com/ && $patch !~ /\.patch$/ && $patch !~ /\.diff$/) { $patch .= '.patch'; } # Parse module name from URL my $module = ""; if ($patch =~ m{ (?| # GitHub/GitLab commit URL https://(?:github|gitlab)\.com/([^/]+)/([^/]+)/commit/([^/]+) | # GitHub pull request commit URL https://github\.com/([^/]+)/([^/]+)/pull/\d+/commits/([^/]+) | # GitLab merge request URL with commit ID https://gitlab\.com/([^/]+)/([^/]+)/-/merge_requests/\d+/diffs\?commit_id=([^&]+) | # GitHub raw URL https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+) | # GitLab raw URL https://gitlab\.com/([^/]+)/([^/]+)/-/raw/([^/]+)/(.+) ) }x) { $module = $2; $module = "" if ($2 eq 'webmin'); # Special handling for some modules $module = $module =~ /^virtualmin-pro$/ ? 'virtual-server/pro' : 'virtual-server' if $module =~ /^virtualmin-(gpl|pro)$/; } # Check if module exists if (!-d "$path/$module") { print "Module '$module' doesn't exist\n"; return 0; } # Prepare patch command my $cmd; my $direct; my $filename; my $dir; my $output; # If raw URL given, try to download and just replace the whole file if ($patch =~ m{^https?://raw\.githubusercontent\.com/} || $patch =~ m{^https?://gitlab\.com/.*?/-/raw/}) { if ($patch =~ m{ .*? # Non-greedy match of everything up to the branch name (?:master|main)/ # Branches name (.*/)? # Capture all directories after the branch (group 1) ([^/]+)$ # Filename (group 2) }x) { $direct = 1; $dir = $1 // ""; $filename = $2; } else { print "Patch failed: Can't parse file name from URL\n"; return 0; } my $cd = "$path/$module/$dir"; $cd =~ s|/+|/|g; chdir "$cd"; $cmd = "curl -w \"%{http_code}\\n\" -s -o $filename @{[quotemeta($patch)]}"; } # Download command elsif ($patch =~ /^https?:\/\//) { $cmd = "curl -L -s @{[quotemeta($patch)]}"; chdir "$path/$module"; } # Local file else { $cmd = "cat @{[quotemeta($patch)]}"; } # Download file directly if ($direct) { $output = `$cmd 2>&1`; if ($output != 200) { print "Patch failed: Cannot download '$filename'. HTTP status code: $output\n"; return 0; } } # Apply patch using patch command elsif (has_command('patch')) { # Do not leave .orig backups behind when hunks apply with fuzz or offset $output = `$cmd 2>&1 | patch -p1 --verbose --no-backup-if-mismatch 2>&1`; if ($output !~ /succeeded/i) { print "Patch failed: $output\n"; return 0; } } # Apply patch using git command else { # If the current directory is inside some git repository (e.g. Webmin # root tracked with git), git would treat patch paths as relative to # that repository root and silently skip them, so stop repository # discovery at the parent directory to always apply patch paths # relative to the current directory local $ENV{'GIT_CEILING_DIRECTORIES'} = dirname(cwd()); # Allow reduced context, similar to the default fuzz used by patch $output = `$cmd 2>&1 | git apply --reject --verbose -C1 --whitespace=fix 2>&1`; if ($output !~ /applied patch.*?cleanly/i) { print "Patch failed: $output\n"; return 0; } } # Print results if ($direct) { print "File replaced successfully:\n"; if ($dir) { $dir = $dir ? "/$dir" : ""; $dir =~ s|^/||; } print " $dir$filename\n"; system("sed -i '1s|^#!/usr/local/bin/perl|#!/usr/bin/perl|' \"$filename\""); } else { print "Patch applied successfully to:\n"; print " $1\n" while $output =~ /^(?|Applied patch\s+(\S+)|patching file\s+(\S+))/mg; } return 1; } =pod =head1 NAME patch =head1 DESCRIPTION Apply a patch to Webmin core or its modules from GitHub/GitLab, a local file, or by downloading and replacing the entire file from a raw URL. Multiple patches can be given at once and are applied in the given order, stopping at the first failure. Webmin is restarted only once after all patches are applied. =head1 SYNOPSIS webmin patch patch-url/file [patch-url/file ...] =head1 OPTIONS =over =item --help, -h Give this help list. =item --config, -c Specify the full path to the Webmin configuration directory. Defaults to C Examples of usage: Apply a patch from a URL. - webmin patch https://github.com/webmin/webmin/commit/e6a2bb15b0.patch - webmin patch https://github.com/virtualmin/virtualmin-gpl/commit/f4433153d Apply a patch from local file. - cd /usr/libexec/webmin/virtual-server/pro && webmin patch /root/virtualmin-pro/patches/patch-1.patch Apply multiple patches at once. - webmin patch https://github.com/webmin/webmin/commit/e6a2bb15b0 \ https://github.com/virtualmin/virtualmin-gpl/commit/f4433153d =back =head1 LICENSE AND COPYRIGHT Copyright 2024 Ilia Ross