- Add "Terminate First" toggle to restore page (UI, Runner, CLI, lib) - When enabled, removes existing cPanel account before restoring - Add GNIZA Backup SVG logo to WHM plugin header (inline base64) - Copy uninstall.sh to /usr/local/gniza/ during installation - Update CLAUDE.md with new restore params and Runner options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
7.1 KiB
Perl
223 lines
7.1 KiB
Perl
package GnizaWHM::Runner;
|
|
# Pattern-based command runner for gniza CLI.
|
|
# Each allowed command has a regex per argument position for safe execution.
|
|
|
|
use strict;
|
|
use warnings;
|
|
use IPC::Open3;
|
|
use Symbol 'gensym';
|
|
use POSIX qw(setsid);
|
|
use File::Temp qw(tempfile);
|
|
|
|
my $GNIZA_BIN = '/usr/local/bin/gniza';
|
|
|
|
# Allowed command patterns.
|
|
# Each entry: [ subcommand, arg_patterns... ]
|
|
# arg_patterns are regexes applied to each positional argument.
|
|
my @ALLOWED = (
|
|
# restore subcommands
|
|
{ cmd => 'restore', subcmd => 'account', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'files', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'database', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'database', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9_]+$/] },
|
|
{ cmd => 'restore', subcmd => 'mailbox', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'mailbox', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/] },
|
|
{ cmd => 'restore', subcmd => 'cron', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'dbusers', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'dbusers', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9_]+$/] },
|
|
{ cmd => 'restore', subcmd => 'cpconfig', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'domains', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'domains', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9._-]+$/] },
|
|
{ cmd => 'restore', subcmd => 'ssl', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'ssl', args => [qr/^[a-z][a-z0-9_-]*$/, qr/^[a-zA-Z0-9._-]+$/] },
|
|
{ cmd => 'restore', subcmd => 'list-databases', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'list-mailboxes', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'list-files', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'list-dbusers', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'list-cron', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'list-dns', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
{ cmd => 'restore', subcmd => 'list-ssl', args => [qr/^[a-z][a-z0-9_-]*$/] },
|
|
# list
|
|
{ cmd => 'list', subcmd => undef, args => [] },
|
|
);
|
|
|
|
# Named option patterns (--key=value).
|
|
my %OPT_PATTERNS = (
|
|
remote => qr/^[a-zA-Z0-9_,-]+$/,
|
|
timestamp => qr/^\d{4}-\d{2}-\d{2}T\d{6}$/,
|
|
path => qr/^[a-zA-Z0-9_.\/@ -]+$/,
|
|
account => qr/^[a-z][a-z0-9_-]*$/,
|
|
exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/,
|
|
terminate => qr/^1$/,
|
|
);
|
|
|
|
# _validate($cmd, $subcmd, \@args, \%opts)
|
|
# Returns (1, undef) on success or (0, $error_msg) on failure.
|
|
sub _validate {
|
|
my ($cmd, $subcmd, $args, $opts) = @_;
|
|
$args //= [];
|
|
$opts //= {};
|
|
|
|
# Find matching allowed pattern
|
|
my $matched;
|
|
for my $pattern (@ALLOWED) {
|
|
next unless $pattern->{cmd} eq $cmd;
|
|
if (defined $pattern->{subcmd}) {
|
|
next unless defined $subcmd && $subcmd eq $pattern->{subcmd};
|
|
} else {
|
|
next if defined $subcmd;
|
|
}
|
|
# Check arg count
|
|
next unless scalar(@$args) == scalar(@{$pattern->{args}});
|
|
# Validate each arg
|
|
my $ok = 1;
|
|
for my $i (0 .. $#$args) {
|
|
unless ($args->[$i] =~ $pattern->{args}[$i]) {
|
|
$ok = 0;
|
|
last;
|
|
}
|
|
}
|
|
if ($ok) {
|
|
$matched = $pattern;
|
|
last;
|
|
}
|
|
}
|
|
|
|
unless ($matched) {
|
|
my $desc = "gniza $cmd" . (defined $subcmd ? " $subcmd" : "") . " " . join(" ", @$args);
|
|
return (0, "Command not allowed: $desc");
|
|
}
|
|
|
|
# Validate options
|
|
for my $key (keys %$opts) {
|
|
my $pat = $OPT_PATTERNS{$key};
|
|
unless ($pat) {
|
|
return (0, "Unknown option: --$key");
|
|
}
|
|
unless ($opts->{$key} =~ $pat) {
|
|
return (0, "Invalid value for --$key: $opts->{$key}");
|
|
}
|
|
}
|
|
|
|
return (1, undef);
|
|
}
|
|
|
|
# _shell_quote($str)
|
|
# Single-quote a string for safe shell embedding.
|
|
sub _shell_quote {
|
|
my ($str) = @_;
|
|
$str =~ s/'/'\\''/g;
|
|
return "'$str'";
|
|
}
|
|
|
|
# _build_cmd_line($cmd, $subcmd, \@args, \%opts)
|
|
# Returns a shell command string.
|
|
sub _build_cmd_line {
|
|
my ($cmd, $subcmd, $args, $opts) = @_;
|
|
$args //= [];
|
|
$opts //= {};
|
|
|
|
my @parts = (_shell_quote($GNIZA_BIN), _shell_quote($cmd));
|
|
push @parts, _shell_quote($subcmd) if defined $subcmd;
|
|
push @parts, _shell_quote($_) for @$args;
|
|
for my $key (sort keys %$opts) {
|
|
push @parts, _shell_quote("--$key=$opts->{$key}");
|
|
}
|
|
|
|
return join(' ', @parts);
|
|
}
|
|
|
|
# run($cmd, $subcmd, \@args, \%opts)
|
|
# Returns ($success, $stdout, $stderr).
|
|
sub run {
|
|
my ($cmd, $subcmd, $args, $opts) = @_;
|
|
$args //= [];
|
|
$opts //= {};
|
|
|
|
my ($valid, $err) = _validate($cmd, $subcmd, $args, $opts);
|
|
unless ($valid) {
|
|
return (0, '', $err);
|
|
}
|
|
|
|
# Build exec args
|
|
my @exec_args = ($cmd);
|
|
push @exec_args, $subcmd if defined $subcmd;
|
|
push @exec_args, @$args;
|
|
for my $key (sort keys %$opts) {
|
|
push @exec_args, "--$key=$opts->{$key}";
|
|
}
|
|
|
|
return _exec(@exec_args);
|
|
}
|
|
|
|
# run_async(\@commands)
|
|
# Each command: [$cmd, $subcmd, \@args, \%opts]
|
|
# Validates all commands upfront, then forks a detached child to run them.
|
|
# Returns ($success, $error_msg).
|
|
sub run_async {
|
|
my ($commands) = @_;
|
|
|
|
return (0, 'No commands provided') unless $commands && @$commands;
|
|
|
|
# Validate all commands upfront
|
|
my @cmd_lines;
|
|
for my $c (@$commands) {
|
|
my ($cmd, $subcmd, $args, $opts) = @$c;
|
|
my ($valid, $err) = _validate($cmd, $subcmd, $args, $opts);
|
|
unless ($valid) {
|
|
return (0, $err);
|
|
}
|
|
push @cmd_lines, _build_cmd_line($cmd, $subcmd, $args, $opts);
|
|
}
|
|
|
|
# Write bash wrapper script
|
|
my ($fh, $tmpfile) = tempfile('gniza-whm-job-XXXXXXXX', DIR => '/tmp', SUFFIX => '.sh');
|
|
print $fh "#!/bin/bash\n";
|
|
print $fh "set -uo pipefail\n";
|
|
for my $line (@cmd_lines) {
|
|
print $fh "$line\n";
|
|
}
|
|
print $fh "rm -f \"\$0\"\n";
|
|
close $fh;
|
|
chmod 0700, $tmpfile;
|
|
|
|
# Fork and detach
|
|
my $pid = fork();
|
|
if (!defined $pid) {
|
|
unlink $tmpfile;
|
|
return (0, "Fork failed: $!");
|
|
}
|
|
|
|
if ($pid == 0) {
|
|
# Child: detach from parent
|
|
setsid();
|
|
open STDIN, '<', '/dev/null';
|
|
open STDOUT, '>', '/dev/null';
|
|
open STDERR, '>', '/dev/null';
|
|
exec('/bin/bash', $tmpfile) or exit(1);
|
|
}
|
|
|
|
# Parent: don't wait for child
|
|
return (1, '');
|
|
}
|
|
|
|
sub _exec {
|
|
my (@args) = @_;
|
|
|
|
my $err = gensym;
|
|
my $pid = open3(my $in, my $out, $err, $GNIZA_BIN, @args);
|
|
close $in;
|
|
|
|
my $stdout = do { local $/; <$out> } // '';
|
|
my $stderr = do { local $/; <$err> } // '';
|
|
close $out;
|
|
close $err;
|
|
|
|
waitpid($pid, 0);
|
|
my $exit_code = $? >> 8;
|
|
|
|
return ($exit_code == 0, $stdout, $stderr);
|
|
}
|
|
|
|
1;
|