package GnizaWHM::UI; # Shared UI helpers: navigation, flash messages, CSRF, HTML escaping, # account list, version detection, mode detection, schedule discovery. use strict; use warnings; use Fcntl qw(:flock O_WRONLY O_CREAT O_EXCL); use IPC::Open3; use Symbol 'gensym'; my $CSRF_DIR = '/var/cpanel/.gniza-whm-csrf'; my $FLASH_DIR = '/var/cpanel/.gniza-whm-flash'; my $CONSTANTS_FILE = '/usr/local/gniza/lib/constants.sh'; my $MAIN_CONFIG = '/etc/gniza/gniza.conf'; my $REMOTES_DIR = '/etc/gniza/remotes.d'; my $SCHEDULES_DIR = '/etc/gniza/schedules.d'; my $TRUEUSERDOMAINS = '/etc/trueuserdomains'; my $REMOTE_EXAMPLE = '/usr/local/gniza/etc/remote.conf.example'; my $SCHEDULE_EXAMPLE = '/usr/local/gniza/etc/schedule.conf.example'; my $SSH_DIR = '/root/.ssh'; my $CSS_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-whm.css'; my $LOGO_FILE = '/usr/local/cpanel/whostmgr/docroot/cgi/gniza-whm/assets/gniza-logo.svg'; my $_logo_data_uri = ''; # populated by page_header(), used by render_nav() # ── HTML Escaping ───────────────────────────────────────────── sub esc { my ($str) = @_; $str //= ''; $str =~ s/&/&/g; $str =~ s//>/g; $str =~ s/"/"/g; $str =~ s/'/'/g; return $str; } # ── Navigation ──────────────────────────────────────────────── my @NAV_ITEMS = ( { url => 'index.cgi', label => 'Dashboard' }, { url => 'remotes.cgi', label => 'Remotes' }, { url => 'schedules.cgi', label => 'Schedules' }, { url => 'restore.cgi', label => 'Restore' }, { url => 'logs.cgi', label => 'Logs' }, { url => 'settings.cgi', label => 'Settings' }, ); sub render_nav { my ($current_page) = @_; my $logo = ''; if ($_logo_data_uri) { $logo = qq{
} . qq{} . qq{GNIZA Backup} . qq{
}; } my $html = qq{\n}; return $html; } # ── Safe file I/O (symlink-attack resistant) ───────────────── sub _safe_write { my ($file, $content) = @_; # Remove existing file/symlink before writing (prevents symlink attacks) unlink $file if -e $file || -l $file; # Create new file with O_CREAT|O_EXCL (fails if file already exists/race) if (sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600) { print $fh $content; close $fh; return 1; } return 0; } sub _safe_read { my ($file) = @_; # Refuse to read symlinks return if -l $file; return unless -f $file; if (open my $fh, '<', $file) { local $/; my $content = <$fh>; close $fh; return $content; } return; } # ── Flash Messages ──────────────────────────────────────────── sub _flash_file { return "$FLASH_DIR/gniza-whm-flash"; } sub set_flash { my ($type, $text) = @_; _ensure_dir($FLASH_DIR); _safe_write(_flash_file(), "$type\n$text\n"); return; } sub get_flash { my $file = _flash_file(); my $content = _safe_read($file); return unless defined $content; unlink $file; my ($type, $text) = split /\n/, $content, 2; return unless defined $type && defined $text; chomp $type; chomp $text; return ($type, $text); } sub render_flash { my @flash = get_flash(); return '' unless defined $flash[0]; my ($type, $text) = @flash; # Validate flash type against allowlist my %valid_types = map { $_ => 1 } qw(success error info warning); $type = 'info' unless $valid_types{$type // ''}; my $escaped = esc($text); return qq{
$escaped
\n}; } sub _ensure_dir { my ($dir) = @_; # Remove stale plain file left by older versions (upgrade path) unlink $dir if -e $dir && ! -d $dir; unless (-d $dir) { mkdir $dir, 0700; } return; } # ── CSRF Protection ────────────────────────────────────────── sub _csrf_file { return $CSRF_DIR; } my $_current_csrf_token; sub generate_csrf_token { # Reuse the same token within a single request so multiple forms # on one page all share the same valid token. return $_current_csrf_token if defined $_current_csrf_token; my $token = ''; if (open my $ufh, '<:raw', '/dev/urandom') { my $bytes; read $ufh, $bytes, 32; close $ufh; $token = unpack('H*', $bytes); } else { # Fallback (should not happen on Linux) for (1..32) { $token .= sprintf('%02x', int(rand(256))); } } _ensure_dir($CSRF_DIR); unless (_safe_write("$CSRF_DIR/token", time() . "\n" . $token . "\n")) { # O_EXCL can fail if unlink didn't fully remove the file; # fall back to plain overwrite so the token is always persisted. if (open my $fh, '>', "$CSRF_DIR/token") { print $fh time() . "\n" . $token . "\n"; close $fh; } } $_current_csrf_token = $token; return $token; } sub verify_csrf_token { my ($submitted) = @_; return 0 unless defined $submitted && $submitted ne ''; my $file = "$CSRF_DIR/token"; my $content = _safe_read($file); return 0 unless defined $content; my ($stored_time, $stored_token) = split /\n/, $content, 2; return 0 unless defined $stored_time && defined $stored_token; chomp $stored_time; chomp $stored_token; # Delete after use (single-use) unlink $file; # Check expiry (1 hour) return 0 if (time() - $stored_time) > 3600; # Constant-time comparison return 0 if length($submitted) != length($stored_token); my $result = 0; for my $i (0 .. length($submitted) - 1) { $result |= ord(substr($submitted, $i, 1)) ^ ord(substr($stored_token, $i, 1)); } return $result == 0; } sub csrf_hidden_field { my $token = generate_csrf_token(); return qq{}; } # ── Account List ────────────────────────────────────────────── sub get_cpanel_accounts { my @accounts; if (open my $fh, '<', $TRUEUSERDOMAINS) { while (my $line = <$fh>) { chomp $line; if ($line =~ /:\s*(\S+)/) { push @accounts, $1; } } close $fh; } my %seen; @accounts = sort grep { !$seen{$_}++ } @accounts; return @accounts; } # ── gniza Version ──────────────────────────────────────────── sub get_gniza_version { if (open my $fh, '<', $CONSTANTS_FILE) { while (my $line = <$fh>) { if ($line =~ /GNIZA_VERSION="([^"]+)"/) { close $fh; return $1; } } close $fh; } return 'unknown'; } # ── Remote Discovery ───────────────────────────────────────── sub has_remotes { return 0 unless -d $REMOTES_DIR; if (opendir my $dh, $REMOTES_DIR) { while (my $entry = readdir $dh) { if ($entry =~ /\.conf$/) { closedir $dh; return 1; } } closedir $dh; } return 0; } sub list_remotes { my @remotes; if (-d $REMOTES_DIR && opendir my $dh, $REMOTES_DIR) { while (my $entry = readdir $dh) { if ($entry =~ /^(.+)\.conf$/) { push @remotes, $1; } } closedir $dh; } return (sort @remotes); } sub remote_conf_path { my ($name) = @_; return "$REMOTES_DIR/$name.conf"; } sub remote_example_path { return $REMOTE_EXAMPLE; } # ── Schedule Discovery ─────────────────────────────────────── sub has_schedules { return 0 unless -d $SCHEDULES_DIR; if (opendir my $dh, $SCHEDULES_DIR) { while (my $entry = readdir $dh) { if ($entry =~ /\.conf$/) { closedir $dh; return 1; } } closedir $dh; } return 0; } sub list_schedules { my @schedules; if (-d $SCHEDULES_DIR && opendir my $dh, $SCHEDULES_DIR) { while (my $entry = readdir $dh) { if ($entry =~ /^(.+)\.conf$/) { push @schedules, $1; } } closedir $dh; } return (sort @schedules); } sub schedule_conf_path { my ($name) = @_; return "$SCHEDULES_DIR/$name.conf"; } sub schedule_example_path { return $SCHEDULE_EXAMPLE; } # ── Configuration Detection ────────────────────────────────── sub is_configured { return has_remotes(); } # ── SSH Key Detection ──────────────────────────────────────── my @SSH_KEY_TYPES = ( { file => 'id_ed25519', type => 'ed25519' }, { file => 'id_rsa', type => 'rsa' }, { file => 'id_ecdsa', type => 'ecdsa' }, { file => 'id_dsa', type => 'dsa' }, ); sub detect_ssh_keys { my @keys; for my $kt (@SSH_KEY_TYPES) { my $path = "$SSH_DIR/$kt->{file}"; next unless -f $path; push @keys, { path => $path, type => $kt->{type}, has_pub => (-f "$path.pub") ? 1 : 0, }; } return \@keys; } sub render_ssh_guidance { my $keys = detect_ssh_keys(); my $html = qq{
\n}; $html .= qq{\n}; $html .= qq{
SSH Key Setup
\n}; $html .= qq{
\n}; if (@$keys) { $html .= qq{

Existing SSH keys detected on this server:

\n}; $html .= qq{
\n}; $html .= qq{\n}; for my $k (@$keys) { my $pub = $k->{has_pub} ? 'Available' : 'Missing'; $html .= qq{}; $html .= qq{}; $html .= qq{\n}; } $html .= qq{
TypePathPublic Key
} . esc($k->{type}) . qq{} . esc($k->{path}) . qq{$pub
\n}; } else { $html .= qq{

No SSH keys found in /root/.ssh/.

\n}; } $html .= qq{

Generate a new key (if needed):

\n}; $html .= qq{
ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""
\n}; $html .= qq{

Copy the public key to the remote server:

\n}; $html .= qq{
ssh-copy-id -i /root/.ssh/id_ed25519.pub user\@host
\n}; $html .= qq{

Run these commands in WHM → Server Configuration → Terminal, or via SSH.

\n}; $html .= qq{
\n
\n}; return $html; } # ── SSH Connection Test ────────────────────────────────────── sub test_ssh_connection { my @params = @_; # Support legacy positional args: ($host, $port, $user, $key) my %args; if (@params == 4 && !ref $params[0]) { %args = (host => $params[0], port => $params[1], user => $params[2], key => $params[3]); } else { %args = @params; } my $host = $args{host}; my $port = $args{port} || '22'; my $user = $args{user} || 'root'; my $auth_method = $args{auth_method} || 'key'; my $key = $args{key} // ''; my $password = $args{password} // ''; my @ssh_args = ( 'ssh', '-n', '-p', $port, '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10', '-o', 'ServerAliveInterval=60', '-o', 'ServerAliveCountMax=3', ); my @cmd; if ($auth_method eq 'password') { push @ssh_args, "$user\@$host", 'echo ok'; @cmd = ('sshpass', '-p', $password, @ssh_args); } else { push @ssh_args, '-i', $key, '-o', 'BatchMode=yes'; push @ssh_args, "$user\@$host", 'echo ok'; @cmd = @ssh_args; } my ($in, $out, $err_fh) = (undef, undef, gensym); my $pid = eval { open3($in, $out, $err_fh, @cmd) }; unless ($pid) { return (0, "Failed to run ssh: $@"); } 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; chomp $stdout; chomp $stderr; if ($exit_code == 0 && $stdout eq 'ok') { return (1, undef); } my $msg = $stderr || "SSH exited with code $exit_code"; return (0, $msg); } # ── Rclone Connection Test ──────────────────────────────────── sub test_rclone_connection { my (%args) = @_; my $type = $args{type} // 's3'; # Build temp rclone config my $tmpfile = "/tmp/gniza-rclone-test-$$.conf"; my $conf_content = ''; my $test_path = ''; if ($type eq 's3') { my $key_id = $args{s3_access_key_id} // ''; my $secret = $args{s3_secret_access_key} // ''; my $region = $args{s3_region} || 'us-east-1'; my $endpoint = $args{s3_endpoint} // ''; my $bucket = $args{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 ''; $test_path = "remote:$bucket"; } elsif ($type eq 'gdrive') { my $sa_file = $args{gdrive_service_account_file} // ''; my $folder_id = $args{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 ''; $test_path = "remote:"; } else { return (0, "Unknown remote type: $type"); } # Write config if (open my $fh, '>', $tmpfile) { print $fh $conf_content; close $fh; chmod 0600, $tmpfile; } else { return (0, "Failed to write temp rclone config: $!"); } # Run rclone lsd my @cmd = ('rclone', '--config', $tmpfile, 'lsd', $test_path); my ($in, $out, $err_fh) = (undef, undef, gensym); my $pid = eval { open3($in, $out, $err_fh, @cmd) }; unless ($pid) { unlink $tmpfile; return (0, "Failed to run rclone: $@"); } 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; unlink $tmpfile; chomp $stderr; if ($exit_code == 0) { return (1, undef); } my $msg = $stderr || "rclone exited with code $exit_code"; return (0, $msg); } # ── Remote Init (create base directory) ────────────────────── sub init_remote_dir { my (%args) = @_; my $type = $args{type} // 'ssh'; my $base = $args{remote_base} // '/backups'; chomp(my $hostname = `hostname -f 2>/dev/null` || `hostname`); my $remote_path = "$base/$hostname/accounts"; if ($type eq 'ssh') { my @ssh_args = ( 'ssh', '-n', '-p', ($args{port} || '22'), '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', ); if (($args{auth_method} // 'key') eq 'password') { push @ssh_args, "$args{user}\@$args{host}", "mkdir -p '$remote_path'"; my @cmd = ('sshpass', '-p', $args{password}, @ssh_args); system(@cmd); } else { push @ssh_args, '-i', $args{key}; push @ssh_args, "$args{user}\@$args{host}", "mkdir -p '$remote_path'"; system(@ssh_args); } return ($? == 0, $? == 0 ? undef : "Failed to create remote directory"); } elsif ($type eq 's3' || $type eq 'gdrive') { my $tmpfile = "/tmp/gniza-rclone-init-$$.conf"; my $conf_content = ''; my $rclone_path = ''; if ($type eq 's3') { $conf_content = "[remote]\ntype = s3\nprovider = AWS\naccess_key_id = $args{s3_access_key_id}\nsecret_access_key = $args{s3_secret_access_key}\nregion = " . ($args{s3_region} || 'us-east-1') . "\n"; $conf_content .= "endpoint = $args{s3_endpoint}\n" if ($args{s3_endpoint} // '') ne ''; $rclone_path = "remote:$args{s3_bucket}$remote_path"; } else { $conf_content = "[remote]\ntype = drive\nscope = drive\nservice_account_file = $args{gdrive_service_account_file}\n"; $conf_content .= "root_folder_id = $args{gdrive_root_folder_id}\n" if ($args{gdrive_root_folder_id} // '') ne ''; $rclone_path = "remote:$remote_path"; } if (open my $fh, '>', $tmpfile) { print $fh $conf_content; close $fh; chmod 0600, $tmpfile; } else { return (0, "Failed to write temp rclone config: $!"); } system('rclone', '--config', $tmpfile, 'mkdir', $rclone_path); my $ok = ($? == 0); unlink $tmpfile; return ($ok, $ok ? undef : "Failed to create remote directory"); } return (0, "Unknown remote type: $type"); } # ── SMTP Connection Test ────────────────────────────────────── sub test_smtp_connection { my (%args) = @_; my $host = $args{host} // ''; my $port = $args{port} || '587'; my $user = $args{user} // ''; my $password = $args{password} // ''; my $from = $args{from} // $user; my $security = $args{security} || 'tls'; my $to = $args{to} // ''; return (0, 'SMTP host is required') if $host eq ''; return (0, 'Recipient email is required') if $to eq ''; return (0, 'From address or SMTP user is required') if $from eq ''; # Build the test email chomp(my $hostname = `hostname -f 2>/dev/null` || `hostname`); my $date = `date -R`; chomp $date; my $message = "From: $from\r\nTo: $to\r\nSubject: [gniza] SMTP Test from $hostname\r\n" . "Content-Type: text/plain; charset=UTF-8\r\nDate: $date\r\n\r\n" . "This is a test email from gniza on $hostname.\r\n" . "If you received this, SMTP is configured correctly.\r\n"; # Write message to temp file my $tmpfile = "/tmp/gniza-smtp-test-$$.eml"; if (open my $fh, '>', $tmpfile) { print $fh $message; close $fh; } else { return (0, "Failed to write temp file: $!"); } # Build curl command my @cmd = ('curl', '--silent', '--show-error', '--connect-timeout', '30', '--max-time', '60'); if ($security eq 'ssl') { push @cmd, '--url', "smtps://$host:$port"; } elsif ($security eq 'tls') { push @cmd, '--url', "smtp://$host:$port", '--ssl-reqd'; } else { push @cmd, '--url', "smtp://$host:$port"; } if ($user ne '') { push @cmd, '--user', "$user:$password"; } push @cmd, '--mail-from', $from; # Support multiple recipients my @recipients = split /\s*,\s*/, $to; for my $rcpt (@recipients) { next if $rcpt eq ''; push @cmd, '--mail-rcpt', $rcpt; } push @cmd, '-T', $tmpfile; my ($in, $out, $err_fh) = (undef, undef, gensym); my $pid = eval { open3($in, $out, $err_fh, @cmd) }; unless ($pid) { unlink $tmpfile; return (0, "Failed to run curl: $@"); } 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; unlink $tmpfile; chomp $stderr; if ($exit_code == 0) { return (1, undef); } my $msg = $stderr || "curl exited with code $exit_code"; return (0, $msg); } # ── Page Wrappers ──────────────────────────────────────────── sub page_header { my ($title) = @_; $title = esc($title // 'GNIZA Backup Manager'); my $css = ''; if (open my $fh, '<', $CSS_FILE) { local $/; $css = <$fh>; close $fh; } # Strip @layer wrappers so our styles are un-layered and compete # with WHM's CSS on normal specificity instead of losing to it. $css = _unwrap_layers($css); # Scope :root/:host to our container so DaisyUI base styles # (background, color, overflow, scrollbar) don't leak into WHM. $css = _scope_to_container($css); # Pre-compute logo data URI for render_nav() if (!$_logo_data_uri && open my $lfh, '<', $LOGO_FILE) { local $/; my $svg_data = <$lfh>; close $lfh; require MIME::Base64; $_logo_data_uri = 'data:image/svg+xml;base64,' . MIME::Base64::encode_base64($svg_data, ''); } return qq{\n} . qq{
\n}; } sub _unwrap_layers { my ($css) = @_; # Loop until all @layer wrappers are removed (handles nesting) while ($css =~ /\@layer\s/) { # Remove @layer order declarations: @layer components; @layer theme, base; $css =~ s/\@layer\s+[\w.,\s]+\s*;//g; # Unwrap @layer name { ... } blocks, keeping inner contents my $out = ''; my $i = 0; my $len = length($css); while ($i < $len) { if (substr($css, $i, 6) eq '@layer') { my $brace = index($css, '{', $i); if ($brace == -1) { $out .= substr($css, $i); last; } my $semi = index($css, ';', $i); if ($semi != -1 && $semi < $brace) { $i = $semi + 1; next; } my $depth = 1; my $j = $brace + 1; while ($j < $len && $depth > 0) { my $c = substr($css, $j, 1); $depth++ if $c eq '{'; $depth-- if $c eq '}'; $j++; } $out .= substr($css, $brace + 1, $j - $brace - 2); $i = $j; } else { $out .= substr($css, $i, 1); $i++; } } $css = $out; } return $css; } sub _scope_to_container { my ($css) = @_; # Step 1: Replace :root/:host with & so CSS variables and base styles # attach to our container element (not lost as descendant selectors). $css =~ s/:root,\s*:host/\&/g; $css =~ s/:where\(:root,\s*\[data-theme[^\]]*\]\)/\&/g; $css =~ s/:where\(:root\)/\&/g; $css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g; $css =~ s/\[data-theme=light\]/\&/g; $css =~ s/\[data-theme=gniza\]/\&/g; $css =~ s/:root:not\(span\)/\&/g; $css =~ s/:root:has\(/\&:has(/g; $css =~ s/:root\b/\&/g; # Step 2: Extract @keyframes and @property to keep at top level my @top_level; my $scoped = ''; my $i = 0; my $len = length($css); while ($i < $len) { if (substr($css, $i, 1) eq '@') { if (substr($css, $i, 11) eq '@keyframes ' || substr($css, $i, 10) eq '@property ') { my $brace = index($css, '{', $i); if ($brace == -1) { $scoped .= substr($css, $i); last; } my $depth = 1; my $j = $brace + 1; while ($j < $len && $depth > 0) { my $c = substr($css, $j, 1); $depth++ if $c eq '{'; $depth-- if $c eq '}'; $j++; } push @top_level, substr($css, $i, $j - $i); $i = $j; next; } } $scoped .= substr($css, $i, 1); $i++; } # Step 3: Wrap in container scope — & references resolve to this selector return join('', @top_level) . '[data-theme="gniza"]{' . $scoped . '}'; } sub page_footer { return qq{\n
\n}; } sub render_errors { my ($errors) = @_; return '' unless $errors && @$errors; my $html = qq{
\n\n
\n"; return $html; } 1;