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);
use IPC::Open3;
use Symbol 'gensym';
my $CSRF_DIR = '/var/cpanel/.gniza-whm-csrf';
my $FLASH_DIR = '/tmp';
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';
# ── HTML Escaping ─────────────────────────────────────────────
sub esc {
my ($str) = @_;
$str //= '';
$str =~ s/&/&/g;
$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 $html = qq{
\n};
for my $item (@NAV_ITEMS) {
my $active = ($item->{url} eq $current_page) ? ' tab-active' : '';
my $label = esc($item->{label});
$html .= qq{
$label\n};
}
$html .= qq{
\n};
return $html;
}
# ── Flash Messages ────────────────────────────────────────────
sub _flash_file {
return "$FLASH_DIR/gniza-whm-flash-$$";
}
sub set_flash {
my ($type, $text) = @_;
my $file = "$FLASH_DIR/gniza-whm-flash";
if (open my $fh, '>', $file) {
print $fh "$type\n$text\n";
close $fh;
}
}
sub get_flash {
my $file = "$FLASH_DIR/gniza-whm-flash";
return undef unless -f $file;
my ($type, $text);
if (open my $fh, '<', $file) {
$type = <$fh>;
$text = <$fh>;
close $fh;
}
unlink $file;
return undef 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;
my $escaped = esc($text);
return qq{$escaped
\n};
}
# ── 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 = '';
for (1..32) {
$token .= sprintf('%02x', int(rand(256)));
}
if (open my $fh, '>', $CSRF_DIR) {
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 '';
return 0 unless -f $CSRF_DIR;
my ($stored_time, $stored_token);
if (open my $fh, '<', $CSRF_DIR) {
$stored_time = <$fh>;
$stored_token = <$fh>;
close $fh;
}
return 0 unless defined $stored_time && defined $stored_token;
chomp $stored_time;
chomp $stored_token;
# Delete after use (single-use)
unlink $CSRF_DIR;
# 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
\n};
$html .= qq{
SSH Key Setup
\n};
if (@$keys) {
$html .= qq{
Existing SSH keys detected on this server:
\n};
$html .= qq{
\n};
$html .= qq{| Type | Path | Public Key |
\n};
for my $k (@$keys) {
my $pub = $k->{has_pub} ? 'Available' : 'Missing';
$html .= qq{| } . esc($k->{type}) . qq{ | };
$html .= qq{} . esc($k->{path}) . qq{ | };
$html .= qq{$pub |
\n};
}
$html .= qq{
\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 (%args) = @_;
# Support legacy positional args: ($host, $port, $user, $key)
if (@_ == 4 && !ref $_[0]) {
%args = (host => $_[0], port => $_[1], user => $_[2], key => $_[3]);
}
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);
return qq{\n}
. qq{\n}
. qq{
$title
\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/: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="light"]{' . $scoped . '}';
}
sub page_footer {
return "\n";
}
sub render_errors {
my ($errors) = @_;
return '' unless $errors && @$errors;
my $html = qq{\n
\n};
for my $err (@$errors) {
$html .= ' - ' . esc($err) . "
\n";
}
$html .= "
\n
\n";
return $html;
}
1;