#!/usr/local/cpanel/3rdparty/bin/perl # gniza4cp WHM Plugin — Remote Destination CRUD use strict; use warnings; use lib '/usr/local/cpanel/whostmgr/docroot/cgi/gniza4cp-whm/lib'; use Whostmgr::HTMLInterface (); use Cpanel::Form (); use File::Copy (); use IPC::Open3 (); use Symbol qw(gensym); use Gniza4cpWHM::Config; use Gniza4cpWHM::Validator; use Gniza4cpWHM::UI; my $form = Cpanel::Form::parseform(); my $method = $ENV{'REQUEST_METHOD'} // 'GET'; my $action = $form->{'action'} // 'list'; # Route to handler 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; # ── Test Connection (JSON) ──────────────────────────────────── sub handle_test_connection { print "Content-Type: application/json\r\n\r\n"; unless ($method eq 'POST' && Gniza4cpWHM::UI::verify_csrf_token($form->{'gniza4cp_csrf'})) { print qq({"success":false,"message":"Invalid or expired token. Please reload and try again."}); exit; } # Generate fresh token after consuming the old one (CSRF is single-use) my $new_csrf = Gniza4cpWHM::UI::generate_csrf_token(); my $type = $form->{'remote_type'} || 'ssh'; if ($type eq 'ssh') { my $host = $form->{'host'} // ''; my $port = $form->{'port'} || '22'; my $user = $form->{'user'} || 'root'; my $auth_method = $form->{'auth_method'} || 'key'; my $key = $form->{'key'} // ''; my $password = $form->{'password'} // ''; if ($host eq '') { print qq({"success":false,"message":"Host is required.","csrf":"$new_csrf"}); exit; } if ($auth_method eq 'password') { if ($password eq '') { print qq({"success":false,"message":"Password is required.","csrf":"$new_csrf"}); exit; } } else { if ($key eq '') { print qq({"success":false,"message":"SSH key path is required.","csrf":"$new_csrf"}); exit; } } my ($ok, $err) = Gniza4cpWHM::UI::test_ssh_connection( host => $host, port => $port, user => $user, auth_method => $auth_method, key => $key, password => $password, ); if ($ok) { print qq({"success":true,"message":"SSH connection successful.","csrf":"$new_csrf"}); } else { $err //= 'Unknown error'; $err =~ s/\\/\\\\/g; $err =~ s/"/\\"/g; $err =~ s/\n/\\n/g; $err =~ s/\r/\\r/g; $err =~ s/\t/\\t/g; $err =~ s/[\x00-\x1f]//g; print qq({"success":false,"message":"SSH connection failed: $err","csrf":"$new_csrf"}); } } elsif ($type eq 's3' || $type eq 'gdrive') { my %rclone_args = (type => $type); if ($type eq 's3') { $rclone_args{s3_access_key_id} = $form->{'S3_ACCESS_KEY_ID'} // ''; $rclone_args{s3_secret_access_key} = $form->{'S3_SECRET_ACCESS_KEY'} // ''; $rclone_args{s3_region} = $form->{'S3_REGION'} || 'us-east-1'; $rclone_args{s3_endpoint} = $form->{'S3_ENDPOINT'} // ''; $rclone_args{s3_bucket} = $form->{'S3_BUCKET'} // ''; if ($rclone_args{s3_access_key_id} eq '' || $rclone_args{s3_secret_access_key} eq '') { print qq({"success":false,"message":"S3 access key and secret are required.","csrf":"$new_csrf"}); exit; } if ($rclone_args{s3_bucket} eq '') { print qq({"success":false,"message":"S3 bucket is required.","csrf":"$new_csrf"}); exit; } } else { $rclone_args{gdrive_service_account_file} = $form->{'GDRIVE_SERVICE_ACCOUNT_FILE'} // ''; $rclone_args{gdrive_root_folder_id} = $form->{'GDRIVE_ROOT_FOLDER_ID'} // ''; if ($rclone_args{gdrive_service_account_file} eq '') { print qq({"success":false,"message":"Service account file path is required.","csrf":"$new_csrf"}); exit; } } my ($ok, $err) = Gniza4cpWHM::UI::test_rclone_connection(%rclone_args); if ($ok) { my $label = $type eq 's3' ? 'S3' : 'Google Drive'; print qq({"success":true,"message":"$label connection successful.","csrf":"$new_csrf"}); } else { $err //= 'Unknown error'; $err =~ s/\\/\\\\/g; $err =~ s/"/\\"/g; $err =~ s/\n/\\n/g; $err =~ s/\r/\\r/g; $err =~ s/\t/\\t/g; $err =~ s/[\x00-\x1f]//g; print qq({"success":false,"message":"Connection failed: $err","csrf":"$new_csrf"}); } } else { print qq({"success":false,"message":"Unknown remote type.","csrf":"$new_csrf"}); } exit; } # ── List ───────────────────────────────────────────────────── sub handle_list { print "Content-Type: text/html\r\n\r\n"; Whostmgr::HTMLInterface::defheader('GNIZA4CP Backup Manager — Remotes', '', '/cgi/gniza4cp-whm/remotes.cgi'); print Gniza4cpWHM::UI::page_header('Remote Destinations'); print Gniza4cpWHM::UI::render_nav('remotes.cgi'); print Gniza4cpWHM::UI::render_flash(); my @remotes = Gniza4cpWHM::UI::list_remotes(); # Build schedule map: remote_name => [schedule_names] my %schedule_map; for my $sname (Gniza4cpWHM::UI::list_schedules()) { my $scfg = Gniza4cpWHM::Config::parse(Gniza4cpWHM::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}; for my $name (@remotes) { my $conf = Gniza4cpWHM::Config::parse(Gniza4cpWHM::UI::remote_conf_path($name), 'remote'); my $esc_name = Gniza4cpWHM::UI::esc($name); my $type = $conf->{REMOTE_TYPE} // 'ssh'; my $retention_raw = $conf->{RETENTION_COUNT} // ''; my $retention = Gniza4cpWHM::UI::esc($retention_raw ne '' ? $retention_raw : '30'); my ($type_label, $dest); if ($type eq 's3') { $type_label = 'S3'; $dest = 's3://' . Gniza4cpWHM::UI::esc($conf->{S3_BUCKET} // ''); } elsif ($type eq 'gdrive') { $type_label = 'GDrive'; my $sa = $conf->{GDRIVE_SERVICE_ACCOUNT_FILE} // ''; $sa =~ s{.*/}{}; $dest = 'gdrive:' . Gniza4cpWHM::UI::esc($sa); } else { $type_label = 'SSH'; my $host = Gniza4cpWHM::UI::esc($conf->{REMOTE_HOST} // ''); my $port = Gniza4cpWHM::UI::esc($conf->{REMOTE_PORT} // '22'); $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 = Gniza4cpWHM::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
NameTypeDestinationDisk UsageSchedulesRetentionActions
$esc_name$type_label$dest$sched_html$retention}; print qq{
}; print qq{}; print qq{
}; print qq{}; print qq{}; print Gniza4cpWHM::UI::csrf_hidden_field(); print qq{}; print qq{
}; print qq{
}; 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/gniza4cp/remotes.d/.

\n}; } print qq{
\n
\n}; print qq{
\n}; print qq{ \n}; print qq{
\n}; print Gniza4cpWHM::UI::page_footer(); 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 = Gniza4cpWHM::Validator::validate_remote_name($name); if ($name_err) { print qq({"ok":false,"disk":"Invalid remote name"}); exit; } my $conf_path = Gniza4cpWHM::UI::remote_conf_path($name); unless (-f $conf_path) { print qq({"ok":false,"disk":"Remote not found"}); exit; } my $conf = Gniza4cpWHM::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/gniza4cp-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 { my @errors; if ($method eq 'POST') { unless (Gniza4cpWHM::UI::verify_csrf_token($form->{'gniza4cp_csrf'})) { push @errors, 'Invalid or expired form token. Please try again.'; } my $name = $form->{'remote_name'} // ''; my $name_err = Gniza4cpWHM::Validator::validate_remote_name($name); push @errors, $name_err if $name_err; if (!@errors && -f Gniza4cpWHM::UI::remote_conf_path($name)) { push @errors, "A remote named '$name' already exists."; } my %data; for my $key (@Gniza4cpWHM::Config::REMOTE_KEYS) { $data{$key} = $form->{$key} // ''; } if (!@errors) { my $validation_errors = Gniza4cpWHM::Validator::validate_remote_config(\%data); push @errors, @$validation_errors; } if (!@errors) { my $type = $data{REMOTE_TYPE} || 'ssh'; my ($conn_ok, $conn_err); if ($type eq 'ssh') { ($conn_ok, $conn_err) = Gniza4cpWHM::UI::test_ssh_connection( host => $data{REMOTE_HOST}, port => $data{REMOTE_PORT} || '22', user => $data{REMOTE_USER} || 'root', auth_method => $data{REMOTE_AUTH_METHOD} || 'key', key => $data{REMOTE_KEY}, password => $data{REMOTE_PASSWORD}, ); } else { my %rclone_args = (type => $type); if ($type eq 's3') { $rclone_args{s3_access_key_id} = $data{S3_ACCESS_KEY_ID}; $rclone_args{s3_secret_access_key} = $data{S3_SECRET_ACCESS_KEY}; $rclone_args{s3_region} = $data{S3_REGION} || 'us-east-1'; $rclone_args{s3_endpoint} = $data{S3_ENDPOINT}; $rclone_args{s3_bucket} = $data{S3_BUCKET}; } else { $rclone_args{gdrive_service_account_file} = $data{GDRIVE_SERVICE_ACCOUNT_FILE}; $rclone_args{gdrive_root_folder_id} = $data{GDRIVE_ROOT_FOLDER_ID}; } ($conn_ok, $conn_err) = Gniza4cpWHM::UI::test_rclone_connection(%rclone_args); } push @errors, "Connection test failed: $conn_err" unless $conn_ok; } if (!@errors) { # Ensure main config exists (gniza4cp backup requires it) _ensure_main_config(); # Copy example template then write values my $dest = Gniza4cpWHM::UI::remote_conf_path($name); my $example = Gniza4cpWHM::UI::remote_example_path(); if (-f $example) { File::Copy::copy($example, $dest) or do { push @errors, "Failed to create remote file: $!"; goto RENDER_ADD; }; } my ($ok, $err) = Gniza4cpWHM::Config::save($dest, \%data, \@Gniza4cpWHM::Config::REMOTE_KEYS); if ($ok) { # Initialize remote directory structure my $type = $data{REMOTE_TYPE} || 'ssh'; my %init_args = ( type => $type, remote_base => $data{REMOTE_BASE} || '/backups', ); if ($type eq 'ssh') { $init_args{host} = $data{REMOTE_HOST}; $init_args{port} = $data{REMOTE_PORT} || '22'; $init_args{user} = $data{REMOTE_USER} || 'root'; $init_args{auth_method} = $data{REMOTE_AUTH_METHOD} || 'key'; $init_args{key} = $data{REMOTE_KEY}; $init_args{password} = $data{REMOTE_PASSWORD}; } elsif ($type eq 's3') { $init_args{s3_access_key_id} = $data{S3_ACCESS_KEY_ID}; $init_args{s3_secret_access_key} = $data{S3_SECRET_ACCESS_KEY}; $init_args{s3_region} = $data{S3_REGION}; $init_args{s3_endpoint} = $data{S3_ENDPOINT}; $init_args{s3_bucket} = $data{S3_BUCKET}; } else { $init_args{gdrive_service_account_file} = $data{GDRIVE_SERVICE_ACCOUNT_FILE}; $init_args{gdrive_root_folder_id} = $data{GDRIVE_ROOT_FOLDER_ID}; } Gniza4cpWHM::UI::init_remote_dir(%init_args); if ($form->{'wizard'}) { Gniza4cpWHM::UI::set_flash('success', "Remote '$name' created. Now set up a schedule."); print "Status: 302 Found\r\n"; print "Location: schedules.cgi?action=add&wizard=1&remote_name=" . _uri_escape($name) . "\r\n\r\n"; exit; } Gniza4cpWHM::UI::set_flash('success', "Remote '$name' created successfully."); print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } else { push @errors, "Failed to save remote config: $err"; } } } RENDER_ADD: print "Content-Type: text/html\r\n\r\n"; Whostmgr::HTMLInterface::defheader('GNIZA4CP Backup Manager — Add Remote', '', '/cgi/gniza4cp-whm/remotes.cgi'); print Gniza4cpWHM::UI::page_header('Add Remote Destination'); print Gniza4cpWHM::UI::render_nav('remotes.cgi'); if (@errors) { print Gniza4cpWHM::UI::render_errors(\@errors); } # Pre-populate from POST if validation failed, else defaults my $conf = {}; if ($method eq 'POST') { for my $key (@Gniza4cpWHM::Config::REMOTE_KEYS) { $conf->{$key} = $form->{$key} // ''; } } elsif ($form->{'key_path'}) { $conf->{REMOTE_KEY} = $form->{'key_path'}; } my $name_val = Gniza4cpWHM::UI::esc($form->{'remote_name'} // ''); render_remote_form($conf, $name_val, 0, $form->{'wizard'} ? 1 : 0); print Gniza4cpWHM::UI::page_footer(); Whostmgr::HTMLInterface::footer(); } # ── Edit ───────────────────────────────────────────────────── sub handle_edit { my $name = $form->{'name'} // ''; my @errors; # Validate name my $name_err = Gniza4cpWHM::Validator::validate_remote_name($name); if ($name_err) { Gniza4cpWHM::UI::set_flash('error', "Invalid remote name."); print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } my $conf_path = Gniza4cpWHM::UI::remote_conf_path($name); unless (-f $conf_path) { Gniza4cpWHM::UI::set_flash('error', "Remote '$name' not found."); print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } if ($method eq 'POST') { unless (Gniza4cpWHM::UI::verify_csrf_token($form->{'gniza4cp_csrf'})) { push @errors, 'Invalid or expired form token. Please try again.'; } my %data; for my $key (@Gniza4cpWHM::Config::REMOTE_KEYS) { $data{$key} = $form->{$key} // ''; } if (!@errors) { my $validation_errors = Gniza4cpWHM::Validator::validate_remote_config(\%data); push @errors, @$validation_errors; } if (!@errors) { my $type = $data{REMOTE_TYPE} || 'ssh'; my ($conn_ok, $conn_err); if ($type eq 'ssh') { ($conn_ok, $conn_err) = Gniza4cpWHM::UI::test_ssh_connection( host => $data{REMOTE_HOST}, port => $data{REMOTE_PORT} || '22', user => $data{REMOTE_USER} || 'root', auth_method => $data{REMOTE_AUTH_METHOD} || 'key', key => $data{REMOTE_KEY}, password => $data{REMOTE_PASSWORD}, ); } else { my %rclone_args = (type => $type); if ($type eq 's3') { $rclone_args{s3_access_key_id} = $data{S3_ACCESS_KEY_ID}; $rclone_args{s3_secret_access_key} = $data{S3_SECRET_ACCESS_KEY}; $rclone_args{s3_region} = $data{S3_REGION} || 'us-east-1'; $rclone_args{s3_endpoint} = $data{S3_ENDPOINT}; $rclone_args{s3_bucket} = $data{S3_BUCKET}; } else { $rclone_args{gdrive_service_account_file} = $data{GDRIVE_SERVICE_ACCOUNT_FILE}; $rclone_args{gdrive_root_folder_id} = $data{GDRIVE_ROOT_FOLDER_ID}; } ($conn_ok, $conn_err) = Gniza4cpWHM::UI::test_rclone_connection(%rclone_args); } push @errors, "Connection test failed: $conn_err" unless $conn_ok; } if (!@errors) { my ($ok, $err) = Gniza4cpWHM::Config::save($conf_path, \%data, \@Gniza4cpWHM::Config::REMOTE_KEYS); if ($ok) { Gniza4cpWHM::UI::set_flash('success', "Remote '$name' updated successfully."); print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } else { push @errors, "Failed to save remote config: $err"; } } } print "Content-Type: text/html\r\n\r\n"; Whostmgr::HTMLInterface::defheader('GNIZA4CP Backup Manager — Edit Remote', '', '/cgi/gniza4cp-whm/remotes.cgi'); print Gniza4cpWHM::UI::page_header("Edit Remote: " . Gniza4cpWHM::UI::esc($name)); print Gniza4cpWHM::UI::render_nav('remotes.cgi'); if (@errors) { print Gniza4cpWHM::UI::render_errors(\@errors); } # Load config (or re-use POST data on error) my $conf; if (@errors && $method eq 'POST') { $conf = {}; for my $key (@Gniza4cpWHM::Config::REMOTE_KEYS) { $conf->{$key} = $form->{$key} // ''; } } else { $conf = Gniza4cpWHM::Config::parse($conf_path, 'remote'); } render_remote_form($conf, Gniza4cpWHM::UI::esc($name), 1); print Gniza4cpWHM::UI::page_footer(); Whostmgr::HTMLInterface::footer(); } # ── Delete ─────────────────────────────────────────────────── sub handle_delete { if ($method ne 'POST') { print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } unless (Gniza4cpWHM::UI::verify_csrf_token($form->{'gniza4cp_csrf'})) { Gniza4cpWHM::UI::set_flash('error', 'Invalid or expired form token.'); print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } my $name = $form->{'name'} // ''; my $name_err = Gniza4cpWHM::Validator::validate_remote_name($name); if ($name_err) { Gniza4cpWHM::UI::set_flash('error', 'Invalid remote name.'); print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } my $conf_path = Gniza4cpWHM::UI::remote_conf_path($name); if (-f $conf_path) { unlink $conf_path; Gniza4cpWHM::UI::set_flash('success', "Remote '$name' deleted."); } else { Gniza4cpWHM::UI::set_flash('error', "Remote '$name' not found."); } print "Status: 302 Found\r\n"; print "Location: remotes.cgi\r\n\r\n"; exit; } # ── Shared Form Renderer ──────────────────────────────────── sub render_remote_form { my ($conf, $name_val, $is_edit, $wizard) = @_; my $action_val = $is_edit ? 'edit' : 'add'; my $remote_type = $conf->{REMOTE_TYPE} // 'ssh'; print qq{
\n}; print qq{\n}; if ($wizard) { print qq{\n}; } print Gniza4cpWHM::UI::csrf_hidden_field(); if ($is_edit) { print qq{\n}; } # Remote name print qq{
\n
\n}; print qq{

Remote Identity

\n}; print qq{
\n}; print qq{ \n}; if ($is_edit) { print qq{ \n}; } else { print qq{ \n}; print qq{ Letters, digits, hyphens, underscores\n}; } print qq{
\n}; # Remote type selector my $ssh_checked = ($remote_type eq 'ssh') ? ' checked' : ''; my $s3_checked = ($remote_type eq 's3') ? ' checked' : ''; my $gdrive_checked = ($remote_type eq 'gdrive') ? ' checked' : ''; print qq{
\n}; print qq{ \n}; print qq{
\n}; print qq{ \n}; print qq{ \n}; print qq{ \n}; print qq{
\n}; print qq{
\n}; print qq{
\n
\n}; # ── SSH fields ──────────────────────────────────────────── my $ssh_hidden = ($remote_type ne 'ssh') ? ' hidden' : ''; # SSH key guidance print qq{
\n}; print Gniza4cpWHM::UI::render_ssh_guidance(); print qq{
\n}; my $auth_method = $conf->{REMOTE_AUTH_METHOD} // 'key'; my $key_checked = ($auth_method ne 'password') ? ' checked' : ''; my $pw_checked = ($auth_method eq 'password') ? ' checked' : ''; my $key_hidden = ($auth_method eq 'password') ? ' hidden' : ''; my $pw_hidden = ($auth_method ne 'password') ? ' hidden' : ''; print qq{
\n}; print qq{
\n
\n}; print qq{

SSH Connection

\n}; _field($conf, 'REMOTE_HOST', 'Hostname / IP', 'Required'); _field($conf, 'REMOTE_PORT', 'SSH Port', 'Default: 22'); _field($conf, 'REMOTE_USER', 'SSH User', 'Default: root'); # Auth method toggle print qq{
\n}; print qq{ \n}; print qq{
\n}; print qq{ \n}; print qq{ \n}; print qq{
\n}; print qq{
\n}; # Key field print qq{
\n}; _field($conf, 'REMOTE_KEY', 'SSH Private Key', 'Absolute path', 'Path to the private key file used for passwordless SSH authentication'); print qq{
\n}; # Password field my $pw_val = Gniza4cpWHM::UI::esc($conf->{REMOTE_PASSWORD} // ''); print qq{
\n}; print qq{
\n}; print qq{ \n}; print qq{ \n}; print qq{ Requires sshpass on server\n}; print qq{
\n}; print qq{
\n}; print qq{
\n
\n}; print qq{
\n}; # ── S3 fields ───────────────────────────────────────────── my $s3_hidden = ($remote_type ne 's3') ? ' hidden' : ''; print qq{
\n}; print qq{
\n
\n}; print qq{

Amazon S3 / S3-Compatible

\n}; _field($conf, 'S3_ACCESS_KEY_ID', 'Access Key ID', 'Required'); _password_field($conf, 'S3_SECRET_ACCESS_KEY', 'Secret Access Key', 'Required'); _field($conf, 'S3_REGION', 'Region', 'Default: us-east-1'); _field($conf, 'S3_ENDPOINT', 'Custom Endpoint', 'For MinIO, Wasabi, etc.', 'Only needed for S3-compatible services, leave empty for AWS'); _field($conf, 'S3_BUCKET', 'Bucket Name', 'Required'); print qq{

Requires rclone installed on this server.

\n}; print qq{
\n
\n}; print qq{
\n}; # ── Google Drive fields ─────────────────────────────────── my $gdrive_hidden = ($remote_type ne 'gdrive') ? ' hidden' : ''; print qq{
\n}; print qq{
\n
\n}; print qq{

Google Drive

\n}; _field($conf, 'GDRIVE_SERVICE_ACCOUNT_FILE', 'Service Account JSON', 'Absolute path, required', 'Google Cloud service account key file for API access'); _field($conf, 'GDRIVE_ROOT_FOLDER_ID', 'Root Folder ID', 'Optional', 'Google Drive folder ID to use as the root for backups'); print qq{

Requires rclone installed on this server.

\n}; print qq{
\n
\n}; print qq{
\n}; # ── Common fields ───────────────────────────────────────── print qq{
\n
\n}; print qq{

Storage Path

\n}; _field($conf, 'REMOTE_BASE', 'Remote Base Dir', 'Default: /backups', 'Root directory on the remote where all backup snapshots are stored'); print qq{
\n
\n}; # Transfer print qq{
\n
\n}; print qq{

Transfer Settings

\n}; _field($conf, 'BWLIMIT', 'Bandwidth Limit', 'KB/s, 0 = unlimited', 'Throttle transfer speed to avoid saturating the network'); print qq{
\n}; _field($conf, 'RSYNC_EXTRA_OPTS', 'Extra rsync Options', 'SSH only', 'Additional flags appended to rsync commands, e.g. --compress'); print qq{
\n}; print qq{
\n
\n}; # Retention print qq{
\n
\n}; print qq{

Retention

\n}; _field($conf, 'RETENTION_COUNT', 'Snapshots to Keep', 'Default: 30', 'Number of backup snapshots to retain per account before pruning old ones'); print qq{
\n
\n}; # Submit print qq{
\n}; my $btn_label = $is_edit ? 'Save Changes' : 'Create Remote'; print qq{ \n}; print qq{ \n}; print qq{ \n}; print qq{
\n}; print qq{
\n}; print qq{
\n}; my $js_csrf = Gniza4cpWHM::UI::esc(Gniza4cpWHM::UI::generate_csrf_token()); print qq{ JS } sub _field { my ($conf, $key, $label, $hint, $tip) = @_; my $val = Gniza4cpWHM::UI::esc($conf->{$key} // ''); my $hint_html = $hint ? qq{ } . Gniza4cpWHM::UI::esc($hint) . qq{} : ''; my $tip_html = $tip ? qq{ } : ''; print qq{
\n}; print qq{ \n}; print qq{ \n}; print qq{ $hint_html\n} if $hint; print qq{
\n}; } sub _password_field { my ($conf, $key, $label, $hint) = @_; my $val = Gniza4cpWHM::UI::esc($conf->{$key} // ''); my $hint_html = $hint ? qq{ } . Gniza4cpWHM::UI::esc($hint) . qq{} : ''; print qq{
\n}; print qq{ \n}; print qq{ \n}; print qq{ $hint_html\n} if $hint; print qq{
\n}; } sub _uri_escape { my ($str) = @_; $str =~ s/([^A-Za-z0-9._~-])/sprintf("%%%02X", ord($1))/ge; return $str; } sub _ensure_main_config { my $config_file = '/etc/gniza4cp/gniza4cp.conf'; return if -f $config_file; # Create dirs for my $dir ('/etc/gniza4cp', '/etc/gniza4cp/remotes.d', '/etc/gniza4cp/schedules.d', '/var/log/gniza4cp') { mkdir $dir unless -d $dir; } # Copy example or write defaults my $example = '/usr/local/gniza4cp/etc/gniza4cp.conf.example'; if (-f $example) { File::Copy::copy($example, $config_file); } else { if (open my $fh, '>', $config_file) { print $fh qq{# gniza4cp configuration — auto-created by WHM plugin\n}; print $fh qq{TEMP_DIR="/usr/local/gniza4cp/workdir"\n}; print $fh qq{INCLUDE_ACCOUNTS=""\n}; print $fh qq{EXCLUDE_ACCOUNTS="nobody"\n}; print $fh qq{LOG_DIR="/var/log/gniza4cp"\n}; print $fh qq{LOG_LEVEL="info"\n}; print $fh qq{LOG_RETAIN=90\n}; print $fh qq{NOTIFY_EMAIL=""\n}; print $fh qq{NOTIFY_ON="failure"\n}; print $fh qq{LOCK_FILE="/var/run/gniza4cp.lock"\n}; print $fh qq{SSH_TIMEOUT=30\n}; print $fh qq{SSH_RETRIES=3\n}; print $fh qq{RSYNC_EXTRA_OPTS=""\n}; close $fh; } } }