#!/usr/local/cpanel/3rdparty/bin/perl package Cpanel::AdminBin::Script::Call::Gniza::Restore; use strict; use warnings; use parent 'Cpanel::AdminBin::Script::Call'; use IPC::Open3; use Symbol 'gensym'; my $GNIZA_BIN = '/usr/local/bin/gniza'; my $MAIN_CONFIG = '/etc/gniza/gniza.conf'; my $REMOTES_DIR = '/etc/gniza/remotes.d'; # Argument validation patterns (mirrors GnizaWHM::Runner) 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_.\/@ -]+$/, exclude => qr/^[a-zA-Z0-9_.,\/@ *?\[\]-]+$/, ); my $ACCOUNT_RE = qr/^[a-z][a-z0-9_-]*$/; my $REMOTE_RE = qr/^[a-zA-Z0-9_-]+$/; my $DBNAME_RE = qr/^[a-zA-Z0-9_]+$/; my $EMAIL_RE = qr/^[a-zA-Z0-9._+-]+\@[a-zA-Z0-9._-]+$/; my $DOMAIN_RE = qr/^[a-zA-Z0-9._-]+$/; my $TS_RE = qr/^\d{4}-\d{2}-\d{2}T\d{6}$/; # ── Allowed remotes for user restore ────────────────────────── sub _get_allowed_remotes { my $setting = ''; if (open my $fh, '<', $MAIN_CONFIG) { while (my $line = <$fh>) { if ($line =~ /^USER_RESTORE_REMOTES=(?:"([^"]*)"|'([^']*)'|(\S*))$/) { $setting = defined $1 ? $1 : (defined $2 ? $2 : ($3 // '')); } } close $fh; } # Default to "all" if not set $setting = 'all' if !defined $setting || $setting eq ''; return $setting; } sub _list_all_remotes { my @remotes; if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) { while (my $entry = readdir $dh) { if ($entry =~ /^([a-zA-Z0-9_-]+)\.conf$/) { push @remotes, $1; } } closedir $dh; } return sort @remotes; } sub _is_remote_allowed { my ($remote) = @_; my $setting = _get_allowed_remotes(); return 0 if $setting eq ''; # disabled if ($setting eq 'all') { # Check it actually exists return -f "$REMOTES_DIR/$remote.conf" ? 1 : 0; } my %allowed = map { $_ => 1 } split /,/, $setting; return $allowed{$remote} ? 1 : 0; } sub _get_filtered_remotes { my $setting = _get_allowed_remotes(); return () if $setting eq ''; my @all = _list_all_remotes(); return @all if $setting eq 'all'; my %allowed = map { $_ => 1 } split /,/, $setting; return grep { $allowed{$_} } @all; } # ── Command execution ───────────────────────────────────────── sub _run_gniza { my (@args) = @_; my $err_fh = gensym; my ($in, $out); my $pid = eval { open3($in, $out, $err_fh, $GNIZA_BIN, @args) }; unless ($pid) { return (0, '', "Failed to execute gniza: $@"); } close $in if $in; my $stdout = do { local $/; <$out> } // ''; my $stderr = do { local $/; <$err_fh> } // ''; close $out; close $err_fh; waitpid($pid, 0); my $exit_code = $? >> 8; return ($exit_code == 0, $stdout, $stderr); } # ── Action dispatch ─────────────────────────────────────────── # ── Per-user activity logging ───────────────────────────────── my $ACTIVITY_ENTRY_RE = qr/^[0-9]+$/; sub _get_log_dir { my $log_dir = '/var/log/gniza'; if (open my $fh, '<', $MAIN_CONFIG) { while (my $line = <$fh>) { if ($line =~ /^LOG_DIR=(?:"([^"]*)"|'([^']*)'|(\S*))$/) { my $val = defined $1 ? $1 : (defined $2 ? $2 : ($3 // '')); $log_dir = $val if $val ne ''; } } close $fh; } return $log_dir; } sub _activity_log_path { my ($user) = @_; my $log_dir = _get_log_dir(); return "$log_dir/cpanel-$user.log"; } my %ACTION_LABELS = ( RESTORE_ACCOUNT => 'Full Account', RESTORE_FILES => 'Home Directory', RESTORE_DATABASE => 'Database', RESTORE_MAILBOX => 'Email', RESTORE_CRON => 'Cron Jobs', RESTORE_DBUSERS => 'DB Users', RESTORE_DOMAINS => 'Domains', RESTORE_SSL => 'SSL Certificates', ); sub _log_activity { my ($user, $action, $details, $status, $output) = @_; my $log_file = _activity_log_path($user); my $log_dir = _get_log_dir(); mkdir $log_dir, 0700 unless -d $log_dir; my @t = gmtime(time); my $ts = sprintf('%04d-%02d-%02d %02d:%02d:%02d', $t[5]+1900, $t[4]+1, $t[3], $t[2], $t[1], $t[0]); my $label = $ACTION_LABELS{$action} // $action; if (open my $fh, '>>', $log_file) { print $fh "--- ENTRY ---\n"; print $fh "Date: $ts\n"; print $fh "Action: $label\n"; print $fh "Details: $details\n"; print $fh "Status: $status\n"; print $fh $output if defined $output && $output ne ''; print $fh "--- END ---\n"; close $fh; } } sub _actions { return qw( LIST_ALLOWED_REMOTES LIST_SNAPSHOTS LIST_DATABASES LIST_MAILBOXES LIST_FILES LIST_DBUSERS LIST_CRON LIST_DNS LIST_SSL LIST_LOGS GET_LOG RESTORE_ACCOUNT RESTORE_FILES RESTORE_DATABASE RESTORE_MAILBOX RESTORE_CRON RESTORE_DBUSERS RESTORE_DOMAINS RESTORE_SSL ); } sub LIST_LOGS { my ($self) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; my $log_file = _activity_log_path($user); return '' unless -f $log_file && !-l $log_file; # Parse entries from the activity log (newest first) my @entries; if (open my $fh, '<', $log_file) { my $in_entry = 0; my %cur; while (my $line = <$fh>) { chomp $line; if ($line eq '--- ENTRY ---') { $in_entry = 1; %cur = (); } elsif ($line eq '--- END ---' && $in_entry) { push @entries, { %cur } if $cur{date}; $in_entry = 0; } elsif ($in_entry) { if ($line =~ /^Date:\s+(.+)$/) { $cur{date} = $1; } elsif ($line =~ /^Action:\s+(.+)$/) { $cur{action} = $1; } elsif ($line =~ /^Details:\s+(.+)$/) { $cur{details} = $1; } elsif ($line =~ /^Status:\s+(.+)$/) { $cur{status} = $1; } } } close $fh; } # Return newest first, one line per entry: index\tdate\taction\tdetails\tstatus my @lines; for my $i (reverse 0 .. $#entries) { my $e = $entries[$i]; push @lines, join("\t", $i, $e->{date} // '', $e->{action} // '', $e->{details} // '', $e->{status} // ''); } return join("\n", @lines); } sub GET_LOG { my ($self, $entry_idx) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid entry" unless defined $entry_idx && $entry_idx =~ $ACTIVITY_ENTRY_RE; my $log_file = _activity_log_path($user); return "ERROR: No activity log" unless -f $log_file && !-l $log_file; # Parse the Nth entry my $idx = int($entry_idx); my @entries; if (open my $fh, '<', $log_file) { my $in_entry = 0; my @cur_lines; while (my $line = <$fh>) { chomp $line; if ($line eq '--- ENTRY ---') { $in_entry = 1; @cur_lines = (); } elsif ($line eq '--- END ---' && $in_entry) { push @entries, join("\n", @cur_lines); $in_entry = 0; } elsif ($in_entry) { push @cur_lines, $line; } } close $fh; } return "ERROR: Entry not found" if $idx < 0 || $idx > $#entries; return $entries[$idx]; } sub LIST_ALLOWED_REMOTES { my ($self) = @_; my @remotes = _get_filtered_remotes(); return join("\n", @remotes); } sub LIST_SNAPSHOTS { my ($self, $remote) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my ($ok, $stdout, $stderr) = _run_gniza('list', "--remote=$remote", "--account=$user"); return $ok ? $stdout : "ERROR: $stderr"; } sub LIST_DATABASES { my ($self, $remote, $timestamp) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-databases', $user, "--remote=$remote", "--timestamp=$timestamp"); return $ok ? $stdout : "ERROR: $stderr"; } sub LIST_MAILBOXES { my ($self, $remote, $timestamp) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-mailboxes', $user, "--remote=$remote", "--timestamp=$timestamp"); return $ok ? $stdout : "ERROR: $stderr"; } sub LIST_FILES { my ($self, $remote, $timestamp, $path) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @opts = ("--remote=$remote", "--timestamp=$timestamp"); if (defined $path && $path ne '') { return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path}; push @opts, "--path=$path"; } my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-files', $user, @opts); return $ok ? $stdout : "ERROR: $stderr"; } sub LIST_DBUSERS { my ($self, $remote, $timestamp) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-dbusers', $user, "--remote=$remote", "--timestamp=$timestamp"); return $ok ? $stdout : "ERROR: $stderr"; } sub LIST_CRON { my ($self, $remote, $timestamp) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-cron', $user, "--remote=$remote", "--timestamp=$timestamp"); return $ok ? $stdout : "ERROR: $stderr"; } sub LIST_DNS { my ($self, $remote, $timestamp) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-dns', $user, "--remote=$remote", "--timestamp=$timestamp"); return $ok ? $stdout : "ERROR: $stderr"; } sub LIST_SSL { my ($self, $remote, $timestamp) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my ($ok, $stdout, $stderr) = _run_gniza('restore', 'list-ssl', $user, "--remote=$remote", "--timestamp=$timestamp"); return $ok ? $stdout : "ERROR: $stderr"; } sub RESTORE_ACCOUNT { my ($self, $remote, $timestamp, $exclude) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @opts = ("--remote=$remote", "--timestamp=$timestamp"); # NOTE: --terminate is NEVER passed for user restore if (defined $exclude && $exclude ne '') { return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude}; push @opts, "--exclude=$exclude"; } my $details = "remote=$remote snapshot=$timestamp"; $details .= " exclude=$exclude" if defined $exclude && $exclude ne ''; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'account', $user, @opts); _log_activity($user, 'RESTORE_ACCOUNT', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } sub RESTORE_FILES { my ($self, $remote, $timestamp, $path, $exclude) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @opts = ("--remote=$remote", "--timestamp=$timestamp"); if (defined $path && $path ne '') { return "ERROR: Invalid path" unless $path =~ $OPT_PATTERNS{path}; push @opts, "--path=$path"; } if (defined $exclude && $exclude ne '') { return "ERROR: Invalid exclude" unless $exclude =~ $OPT_PATTERNS{exclude}; push @opts, "--exclude=$exclude"; } my $details = "remote=$remote snapshot=$timestamp"; $details .= " path=$path" if defined $path && $path ne ''; $details .= " exclude=$exclude" if defined $exclude && $exclude ne ''; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'files', $user, @opts); _log_activity($user, 'RESTORE_FILES', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } sub RESTORE_DATABASE { my ($self, $remote, $timestamp, $dbname) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @args = ($user); if (defined $dbname && $dbname ne '') { return "ERROR: Invalid database name" unless $dbname =~ $DBNAME_RE; push @args, $dbname; } my $details = "remote=$remote snapshot=$timestamp"; $details .= " database=$dbname" if defined $dbname && $dbname ne ''; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'database', @args, "--remote=$remote", "--timestamp=$timestamp"); _log_activity($user, 'RESTORE_DATABASE', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } sub RESTORE_MAILBOX { my ($self, $remote, $timestamp, $email) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @args = ($user); if (defined $email && $email ne '') { return "ERROR: Invalid email" unless $email =~ $EMAIL_RE; push @args, $email; } my $details = "remote=$remote snapshot=$timestamp"; $details .= " email=$email" if defined $email && $email ne ''; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'mailbox', @args, "--remote=$remote", "--timestamp=$timestamp"); _log_activity($user, 'RESTORE_MAILBOX', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } sub RESTORE_CRON { my ($self, $remote, $timestamp) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my $details = "remote=$remote snapshot=$timestamp"; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'cron', $user, "--remote=$remote", "--timestamp=$timestamp"); _log_activity($user, 'RESTORE_CRON', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } sub RESTORE_DBUSERS { my ($self, $remote, $timestamp, $dbuser) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @args = ($user); if (defined $dbuser && $dbuser ne '') { return "ERROR: Invalid database user" unless $dbuser =~ $DBNAME_RE; push @args, $dbuser; } my $details = "remote=$remote snapshot=$timestamp"; $details .= " dbuser=$dbuser" if defined $dbuser && $dbuser ne ''; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'dbusers', @args, "--remote=$remote", "--timestamp=$timestamp"); _log_activity($user, 'RESTORE_DBUSERS', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } sub RESTORE_DOMAINS { my ($self, $remote, $timestamp, $domain) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @args = ($user); if (defined $domain && $domain ne '') { return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE; push @args, $domain; } my $details = "remote=$remote snapshot=$timestamp"; $details .= " domain=$domain" if defined $domain && $domain ne ''; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'domains', @args, "--remote=$remote", "--timestamp=$timestamp"); _log_activity($user, 'RESTORE_DOMAINS', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } sub RESTORE_SSL { my ($self, $remote, $timestamp, $domain) = @_; my $user = $self->get_caller_username() // ''; return "ERROR: Invalid user" unless $user =~ $ACCOUNT_RE; return "ERROR: Invalid remote" unless defined $remote && $remote =~ $REMOTE_RE; return "ERROR: Invalid timestamp" unless defined $timestamp && $timestamp =~ $TS_RE; return "ERROR: Remote not allowed" unless _is_remote_allowed($remote); my @args = ($user); if (defined $domain && $domain ne '') { return "ERROR: Invalid domain" unless $domain =~ $DOMAIN_RE; push @args, $domain; } my $details = "remote=$remote snapshot=$timestamp"; $details .= " domain=$domain" if defined $domain && $domain ne ''; my ($ok, $stdout, $stderr) = _run_gniza('restore', 'ssl', @args, "--remote=$remote", "--timestamp=$timestamp"); _log_activity($user, 'RESTORE_SSL', $details, $ok ? 'OK' : 'Error', $ok ? $stdout : $stderr); return $ok ? "OK\n$stdout" : "ERROR: $stderr"; } __PACKAGE__->run() if !caller; 1;