Files
gniza4cp/cpanel/admin/Gniza/Restore
shuki c20c019048 Add cPanel user activity logs page and WHM user log visibility
- Add per-user activity logging to AdminBin: every RESTORE_* action
  writes to /var/log/gniza/cpanel-<user>.log with action details and
  gniza command output
- New logs.live.cgi CGI with paginated activity list and detail view
- WHM logs.cgi now shows cpanel-*.log files with Owner column and
  structured activity entry viewer with expandable command output
- Add Logs nav item to cPanel plugin, update install.sh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:37:08 +02:00

589 lines
21 KiB
Perl

#!/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;