diff --git a/whm/gniza-whm/remotes.cgi b/whm/gniza-whm/remotes.cgi index 3de29d3..05c87b9 100644 --- a/whm/gniza-whm/remotes.cgi +++ b/whm/gniza-whm/remotes.cgi @@ -8,6 +8,8 @@ use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/lib'; use Whostmgr::HTMLInterface (); use Cpanel::Form (); use File::Copy (); +use IPC::Open3 (); +use Symbol qw(gensym); use GnizaWHM::Config; use GnizaWHM::Validator; use GnizaWHM::UI; @@ -17,11 +19,12 @@ my $method = $ENV{'REQUEST_METHOD'} // 'GET'; my $action = $form->{'action'} // 'list'; # Route to handler -if ($action eq 'test') { handle_test_connection() } -elsif ($action eq 'add') { handle_add() } -elsif ($action eq 'edit') { handle_edit() } -elsif ($action eq 'delete') { handle_delete() } -else { handle_list() } +if ($action eq 'test') { handle_test_connection() } +elsif ($action eq 'disk_info') { handle_disk_info() } +elsif ($action eq 'add') { handle_add() } +elsif ($action eq 'edit') { handle_edit() } +elsif ($action eq 'delete') { handle_delete() } +else { handle_list() } exit; @@ -146,11 +149,27 @@ sub handle_list { my @remotes = GnizaWHM::UI::list_remotes(); + # Build schedule map: remote_name => [schedule_names] + my %schedule_map; + for my $sname (GnizaWHM::UI::list_schedules()) { + my $scfg = GnizaWHM::Config::parse(GnizaWHM::UI::schedule_conf_path($sname), 'schedule'); + my $remotes_str = $scfg->{REMOTES} // ''; + if ($remotes_str eq '') { + # Empty REMOTES = targets ALL remotes + for my $r (@remotes) { push @{$schedule_map{$r}}, $sname; } + } else { + for my $r (split /,/, $remotes_str) { + $r =~ s/^\s+|\s+$//g; + push @{$schedule_map{$r}}, $sname if $r ne ''; + } + } + } + print qq{
\n
\n}; if (@remotes) { print qq{
\n}; - print qq{\n}; + print qq{\n}; print qq{\n}; for my $name (@remotes) { my $conf = GnizaWHM::Config::parse(GnizaWHM::UI::remote_conf_path($name), 'remote'); @@ -175,10 +194,24 @@ sub handle_list { $dest = "$host:$port"; } + # Schedule count + tooltip + my @sched_names = @{$schedule_map{$name} // []}; + my $sched_count = scalar @sched_names; + my $sched_html; + if ($sched_count > 0) { + my $sched_tip = GnizaWHM::UI::esc(join(', ', @sched_names)); + $sched_html = qq{$sched_count}; + } else { + $sched_html = qq{0}; + } + print qq{}; print qq{}; print qq{}; - print qq{}; + print qq{}; + print qq{}; + print qq{}; + print qq{}; print qq{\n}; } print qq{\n
NameTypeDestinationRetentionActions
NameTypeDestinationDisk UsageSchedulesRetentionActions
$esc_name$type_label$dest$retention$dest$sched_html$retention}; print qq{
}; print qq{}; @@ -193,6 +226,35 @@ sub handle_list { print qq{
\n}; + + # JavaScript: fetch disk info for each remote via safe DOM methods + print <<'JS'; + +JS } else { print qq{

No remote destinations configured. Add a remote to enable multi-remote backups.

\n}; print qq{

Remote configs are stored in /etc/gniza/remotes.d/.

\n}; @@ -208,6 +270,216 @@ sub handle_list { Whostmgr::HTMLInterface::footer(); } +# ── Disk Info (JSON, GET) ──────────────────────────────────── + +sub handle_disk_info { + print "Content-Type: application/json\r\n\r\n"; + + my $name = $form->{'name'} // ''; + my $name_err = GnizaWHM::Validator::validate_remote_name($name); + if ($name_err) { + print qq({"ok":false,"disk":"Invalid remote name"}); + exit; + } + + my $conf_path = GnizaWHM::UI::remote_conf_path($name); + unless (-f $conf_path) { + print qq({"ok":false,"disk":"Remote not found"}); + exit; + } + + my $conf = GnizaWHM::Config::parse($conf_path, 'remote'); + my $type = $conf->{REMOTE_TYPE} // 'ssh'; + my $base = $conf->{REMOTE_BASE} // '/backups'; + + my $disk_str = eval { _get_disk_info($conf, $type, $base) }; + if ($@ || !defined $disk_str) { + my $err = $@ || 'Unknown error'; + $err =~ s/[\r\n]/ /g; + $err =~ s/"/\\"/g; + print qq({"ok":false,"disk":"Connection failed"}); + exit; + } + + $disk_str =~ s/\\/\\\\/g; + $disk_str =~ s/"/\\"/g; + $disk_str =~ s/\n/\\n/g; + $disk_str =~ s/\r/\\r/g; + $disk_str =~ s/[\x00-\x1f]//g; + print qq({"ok":true,"disk":"$disk_str"}); + exit; +} + +sub _get_disk_info { + my ($conf, $type, $base) = @_; + + if ($type eq 'ssh') { + return _get_disk_info_ssh($conf, $base); + } else { + return _get_disk_info_rclone($conf, $type, $base); + } +} + +sub _get_disk_info_ssh { + my ($conf, $base) = @_; + + my $host = $conf->{REMOTE_HOST} // ''; + my $port = $conf->{REMOTE_PORT} || '22'; + my $user = $conf->{REMOTE_USER} || 'root'; + my $auth_method = $conf->{REMOTE_AUTH_METHOD} || 'key'; + my $key = $conf->{REMOTE_KEY} // ''; + my $password = $conf->{REMOTE_PASSWORD} // ''; + + my @ssh_args = ( + 'ssh', '-n', + '-p', $port, + '-o', 'StrictHostKeyChecking=accept-new', + '-o', 'ConnectTimeout=10', + '-o', 'BatchMode=yes', + ); + + # Build df command — quote $base for remote shell + (my $safe_base = $base) =~ s/'/'\\''/g; + my $remote_cmd = "df -B1 '$safe_base' 2>/dev/null | tail -1"; + + my @cmd; + if ($auth_method eq 'password') { + # Remove BatchMode for password auth + @ssh_args = grep { $_ ne 'BatchMode=yes' } @ssh_args; + push @ssh_args, "$user\@$host", $remote_cmd; + $ENV{SSHPASS} = $password; + @cmd = ('sshpass', '-e', @ssh_args); + } else { + push @ssh_args, '-i', $key; + push @ssh_args, "$user\@$host", $remote_cmd; + @cmd = @ssh_args; + } + + my ($in, $out, $err_fh) = (undef, undef, gensym); + my $pid = eval { IPC::Open3::open3($in, $out, $err_fh, @cmd) }; + delete $ENV{SSHPASS}; + return undef unless $pid; + close $in if $in; + + # Timeout: kill subprocess after 20 seconds + local $SIG{ALRM} = sub { kill 'TERM', $pid; }; + alarm(20); + + my $stdout = do { local $/; <$out> } // ''; + close $out; + close $err_fh; + waitpid($pid, 0); + alarm(0); + + return undef if ($? >> 8) != 0; + + chomp $stdout; + return undef if $stdout eq ''; + + # df -B1 output: Filesystem 1B-blocks Used Available Use% Mounted + my @fields = split /\s+/, $stdout; + return undef if @fields < 4; + + my ($total, $used, $avail) = ($fields[1], $fields[2], $fields[3]); + return undef unless $total =~ /^\d+$/ && $used =~ /^\d+$/ && $avail =~ /^\d+$/; + + return _human_size($used) . ' used / ' . _human_size($total) . ' total (' . _human_size($avail) . ' free)'; +} + +sub _get_disk_info_rclone { + my ($conf, $type, $base) = @_; + + # Build temp rclone config + my $tmpfile = "/tmp/gniza-rclone-disk-$$.conf"; + my $conf_content = ''; + my $about_path = ''; + + if ($type eq 's3') { + my $key_id = $conf->{S3_ACCESS_KEY_ID} // ''; + my $secret = $conf->{S3_SECRET_ACCESS_KEY} // ''; + my $region = $conf->{S3_REGION} || 'us-east-1'; + my $endpoint = $conf->{S3_ENDPOINT} // ''; + my $bucket = $conf->{S3_BUCKET} // ''; + + $conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $key_id\nsecret_access_key = $secret\nregion = $region\n"; + $conf_content .= "endpoint = $endpoint\n" if $endpoint ne ''; + $about_path = "remote:$bucket$base"; + } + elsif ($type eq 'gdrive') { + my $sa_file = $conf->{GDRIVE_SERVICE_ACCOUNT_FILE} // ''; + my $folder_id = $conf->{GDRIVE_ROOT_FOLDER_ID} // ''; + + $conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $sa_file\n"; + $conf_content .= "root_folder_id = $folder_id\n" if $folder_id ne ''; + $about_path = "remote:$base"; + } + else { + return undef; + } + + if (open my $fh, '>', $tmpfile) { + print $fh $conf_content; + close $fh; + chmod 0600, $tmpfile; + } else { + return undef; + } + + my @cmd = ('rclone', '--config', $tmpfile, '--timeout', '15s', 'about', '--json', $about_path); + my ($in, $out, $err_fh) = (undef, undef, gensym); + my $pid = eval { IPC::Open3::open3($in, $out, $err_fh, @cmd) }; + unless ($pid) { + unlink $tmpfile; + return undef; + } + close $in if $in; + + # Timeout: kill subprocess after 20 seconds + local $SIG{ALRM} = sub { kill 'TERM', $pid; }; + alarm(20); + + my $stdout = do { local $/; <$out> } // ''; + close $out; + close $err_fh; + waitpid($pid, 0); + alarm(0); + my $exit_code = $? >> 8; + unlink $tmpfile; + + return undef if $exit_code != 0; + + # Parse JSON fields: used, total, free + my ($used) = ($stdout =~ /"used"\s*:\s*(\d+)/); + my ($total) = ($stdout =~ /"total"\s*:\s*(\d+)/); + my ($free) = ($stdout =~ /"free"\s*:\s*(\d+)/); + + if (defined $used && defined $total && $total > 0) { + my $free_str = defined $free ? ' (' . _human_size($free) . ' free)' : ''; + return _human_size($used) . ' used / ' . _human_size($total) . ' total' . $free_str; + } elsif (defined $used) { + return _human_size($used) . ' used'; + } + + return undef; +} + +sub _human_size { + my ($bytes) = @_; + return '0 B' unless defined $bytes && $bytes >= 0; + + my @units = ('B', 'KB', 'MB', 'GB', 'TB', 'PB'); + my $i = 0; + my $size = $bytes + 0.0; + while ($size >= 1024 && $i < $#units) { + $size /= 1024; + $i++; + } + if ($i == 0) { + return sprintf('%d %s', $size, $units[$i]); + } + return sprintf('%.2f %s', $size, $units[$i]); +} + # ── Add ────────────────────────────────────────────────────── sub handle_add {