Files
gniza4cp/whm/gniza-whm/lib/GnizaWHM/Validator.pm
shuki fac7dc6c80 Add SMTP notification support with WHM settings UI
Send email via curl SMTP when SMTP_HOST is configured, falling back
to system mail/sendmail when empty. NOTIFY_EMAIL now accepts
comma-separated addresses. WHM Settings page gets an SMTP card
with Send Test Email button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 03:46:47 +02:00

321 lines
11 KiB
Perl

package GnizaWHM::Validator;
# Input validation mirroring lib/config.sh validate_config()
# and lib/remotes.sh validate_remote().
use strict;
use warnings;
# validate_main_config(\%data, $has_remotes)
# Returns arrayref of error strings (empty = valid).
sub validate_main_config {
my ($data) = @_;
my @errors;
if (defined $data->{NOTIFY_ON} && $data->{NOTIFY_ON} ne '') {
unless ($data->{NOTIFY_ON} =~ /^(always|failure|never)$/) {
push @errors, 'NOTIFY_ON must be always, failure, or never';
}
}
if (defined $data->{LOG_LEVEL} && $data->{LOG_LEVEL} ne '') {
unless ($data->{LOG_LEVEL} =~ /^(debug|info|warn|error)$/) {
push @errors, 'LOG_LEVEL must be debug, info, warn, or error';
}
}
if (defined $data->{LOG_RETAIN} && $data->{LOG_RETAIN} ne '') {
push @errors, _validate_positive_int('LOG_RETAIN', $data->{LOG_RETAIN});
}
if (defined $data->{SSH_TIMEOUT} && $data->{SSH_TIMEOUT} ne '') {
push @errors, _validate_non_negative_int('SSH_TIMEOUT', $data->{SSH_TIMEOUT});
}
if (defined $data->{SSH_RETRIES} && $data->{SSH_RETRIES} ne '') {
push @errors, _validate_positive_int('SSH_RETRIES', $data->{SSH_RETRIES});
}
if (defined $data->{INCLUDE_ACCOUNTS} && $data->{INCLUDE_ACCOUNTS} ne '') {
if ($data->{INCLUDE_ACCOUNTS} !~ /^[a-zA-Z0-9_, ]*$/) {
push @errors, 'INCLUDE_ACCOUNTS contains invalid characters';
}
}
if (defined $data->{EXCLUDE_ACCOUNTS} && $data->{EXCLUDE_ACCOUNTS} ne '') {
if ($data->{EXCLUDE_ACCOUNTS} !~ /^[a-zA-Z0-9_, ]*$/) {
push @errors, 'EXCLUDE_ACCOUNTS contains invalid characters';
}
}
if (defined $data->{RSYNC_EXTRA_OPTS} && $data->{RSYNC_EXTRA_OPTS} ne '') {
if ($data->{RSYNC_EXTRA_OPTS} !~ /^[a-zA-Z0-9 ._=\/-]*$/) {
push @errors, 'RSYNC_EXTRA_OPTS contains invalid characters';
}
}
if (defined $data->{TEMP_DIR} && $data->{TEMP_DIR} ne '') {
if ($data->{TEMP_DIR} !~ /^\/[\w\/.+-]*$/) {
push @errors, 'TEMP_DIR must be an absolute path';
}
}
if (defined $data->{LOG_DIR} && $data->{LOG_DIR} ne '') {
if ($data->{LOG_DIR} !~ /^\/[\w\/.+-]*$/) {
push @errors, 'LOG_DIR must be an absolute path';
}
}
if (defined $data->{LOCK_FILE} && $data->{LOCK_FILE} ne '') {
if ($data->{LOCK_FILE} !~ /^\/[\w\/.+-]*$/) {
push @errors, 'LOCK_FILE must be an absolute path';
}
}
# SMTP validation (only when SMTP_HOST is set)
if (defined $data->{SMTP_HOST} && $data->{SMTP_HOST} ne '') {
if ($data->{SMTP_HOST} !~ /^[a-zA-Z0-9._-]+$/) {
push @errors, 'SMTP_HOST contains invalid characters';
}
if (defined $data->{SMTP_PORT} && $data->{SMTP_PORT} ne '') {
if ($data->{SMTP_PORT} !~ /^\d+$/ || $data->{SMTP_PORT} < 1 || $data->{SMTP_PORT} > 65535) {
push @errors, 'SMTP_PORT must be 1-65535';
}
}
if (defined $data->{SMTP_SECURITY} && $data->{SMTP_SECURITY} ne '') {
unless ($data->{SMTP_SECURITY} =~ /^(tls|ssl|none)$/) {
push @errors, 'SMTP_SECURITY must be tls, ssl, or none';
}
}
if (defined $data->{SMTP_FROM} && $data->{SMTP_FROM} ne '') {
unless ($data->{SMTP_FROM} =~ /^[^\s@]+\@[^\s@]+\.[^\s@]+$/) {
push @errors, 'SMTP_FROM must be a valid email address';
}
}
}
# Validate NOTIFY_EMAIL addresses (comma-separated)
if (defined $data->{NOTIFY_EMAIL} && $data->{NOTIFY_EMAIL} ne '') {
my @addrs = split /\s*,\s*/, $data->{NOTIFY_EMAIL};
for my $addr (@addrs) {
next if $addr eq '';
unless ($addr =~ /^[^\s@]+\@[^\s@]+\.[^\s@]+$/) {
push @errors, "Invalid email address: $addr";
}
}
}
# Filter out empty strings from helper returns
return [grep { $_ ne '' } @errors];
}
# validate_remote_config(\%data)
# Returns arrayref of error strings (empty = valid).
sub validate_remote_config {
my ($data) = @_;
my @errors;
my $type = $data->{REMOTE_TYPE} // 'ssh';
unless ($type =~ /^(ssh|s3|gdrive)$/) {
push @errors, 'REMOTE_TYPE must be ssh, s3, or gdrive';
return [grep { $_ ne '' } @errors];
}
# Common validations
push @errors, _validate_positive_int('RETENTION_COUNT', $data->{RETENTION_COUNT});
push @errors, _validate_non_negative_int('BWLIMIT', $data->{BWLIMIT});
if (defined $data->{REMOTE_BASE} && $data->{REMOTE_BASE} ne '') {
if ($data->{REMOTE_BASE} !~ /^\/[\w\/.+-]*$/) {
push @errors, 'REMOTE_BASE must be an absolute path';
}
}
if ($type eq 'ssh') {
# SSH-specific validation
if (!defined $data->{REMOTE_HOST} || $data->{REMOTE_HOST} eq '') {
push @errors, 'REMOTE_HOST is required';
} elsif ($data->{REMOTE_HOST} !~ /^[a-zA-Z0-9._-]+$/) {
push @errors, 'REMOTE_HOST contains invalid characters';
}
my $auth_method = $data->{REMOTE_AUTH_METHOD} // 'key';
if ($auth_method ne 'key' && $auth_method ne 'password') {
push @errors, 'REMOTE_AUTH_METHOD must be key or password';
}
if ($auth_method eq 'password') {
if (!defined $data->{REMOTE_PASSWORD} || $data->{REMOTE_PASSWORD} eq '') {
push @errors, 'REMOTE_PASSWORD is required for password authentication';
}
} else {
if (!defined $data->{REMOTE_KEY} || $data->{REMOTE_KEY} eq '') {
push @errors, 'REMOTE_KEY is required';
} elsif ($data->{REMOTE_KEY} !~ /^\/[\w\/.+-]+$/) {
push @errors, 'REMOTE_KEY must be an absolute path';
} elsif (!-f $data->{REMOTE_KEY}) {
push @errors, "REMOTE_KEY file not found: $data->{REMOTE_KEY}";
}
}
push @errors, _validate_port($data->{REMOTE_PORT});
if (defined $data->{REMOTE_USER} && $data->{REMOTE_USER} ne '') {
if ($data->{REMOTE_USER} !~ /^[a-z_][a-z0-9_-]*$/) {
push @errors, 'REMOTE_USER contains invalid characters';
}
}
if (defined $data->{RSYNC_EXTRA_OPTS} && $data->{RSYNC_EXTRA_OPTS} ne '') {
if ($data->{RSYNC_EXTRA_OPTS} !~ /^[a-zA-Z0-9 ._=\/-]*$/) {
push @errors, 'RSYNC_EXTRA_OPTS contains invalid characters';
}
}
}
elsif ($type eq 's3') {
if (!defined $data->{S3_ACCESS_KEY_ID} || $data->{S3_ACCESS_KEY_ID} eq '') {
push @errors, 'S3_ACCESS_KEY_ID is required';
}
if (!defined $data->{S3_SECRET_ACCESS_KEY} || $data->{S3_SECRET_ACCESS_KEY} eq '') {
push @errors, 'S3_SECRET_ACCESS_KEY is required';
}
if (!defined $data->{S3_BUCKET} || $data->{S3_BUCKET} eq '') {
push @errors, 'S3_BUCKET is required';
} elsif ($data->{S3_BUCKET} !~ /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/) {
push @errors, 'S3_BUCKET contains invalid characters';
}
if (defined $data->{S3_REGION} && $data->{S3_REGION} ne '') {
if ($data->{S3_REGION} !~ /^[a-z0-9-]+$/) {
push @errors, 'S3_REGION contains invalid characters';
}
}
}
elsif ($type eq 'gdrive') {
if (!defined $data->{GDRIVE_SERVICE_ACCOUNT_FILE} || $data->{GDRIVE_SERVICE_ACCOUNT_FILE} eq '') {
push @errors, 'GDRIVE_SERVICE_ACCOUNT_FILE is required';
} elsif ($data->{GDRIVE_SERVICE_ACCOUNT_FILE} !~ /^\/[\w\/.+-]+$/) {
push @errors, 'GDRIVE_SERVICE_ACCOUNT_FILE must be an absolute path';
} elsif (!-f $data->{GDRIVE_SERVICE_ACCOUNT_FILE}) {
push @errors, "GDRIVE_SERVICE_ACCOUNT_FILE not found: $data->{GDRIVE_SERVICE_ACCOUNT_FILE}";
}
}
return [grep { $_ ne '' } @errors];
}
# validate_remote_name($name)
# Returns error string or empty string if valid.
sub validate_remote_name {
my ($name) = @_;
if (!defined $name || $name eq '') {
return 'Remote name is required';
}
if ($name !~ /^[a-zA-Z0-9_-]+$/) {
return 'Remote name may only contain letters, digits, hyphens, and underscores';
}
if (length($name) > 64) {
return 'Remote name is too long (max 64 characters)';
}
return '';
}
# validate_schedule_config(\%data)
# Returns arrayref of error strings (empty = valid).
sub validate_schedule_config {
my ($data) = @_;
my @errors;
my $schedule = $data->{SCHEDULE} // '';
if ($schedule eq '') {
push @errors, 'SCHEDULE is required';
} elsif ($schedule !~ /^(hourly|daily|weekly|monthly|custom)$/) {
push @errors, 'SCHEDULE must be hourly, daily, weekly, monthly, or custom';
}
my $stime = $data->{SCHEDULE_TIME} // '';
if ($stime ne '' && $stime !~ /^([01]\d|2[0-3]):[0-5]\d$/) {
push @errors, 'SCHEDULE_TIME must be HH:MM (24-hour format)';
}
if ($schedule eq 'hourly') {
my $sday = $data->{SCHEDULE_DAY} // '';
if ($sday ne '' && ($sday !~ /^\d+$/ || $sday < 1 || $sday > 23)) {
push @errors, 'SCHEDULE_DAY must be 1-23 (hours between backups) for hourly schedule';
}
} elsif ($schedule eq 'weekly') {
my $sday = $data->{SCHEDULE_DAY} // '';
if ($sday eq '' || $sday !~ /^[0-6]$/) {
push @errors, 'SCHEDULE_DAY must be 0-6 for weekly schedule';
}
} elsif ($schedule eq 'monthly') {
my $sday = $data->{SCHEDULE_DAY} // '';
if ($sday eq '' || $sday !~ /^\d+$/ || $sday < 1 || $sday > 28) {
push @errors, 'SCHEDULE_DAY must be 1-28 for monthly schedule';
}
} elsif ($schedule eq 'custom') {
my $scron = $data->{SCHEDULE_CRON} // '';
if ($scron eq '' || $scron !~ /^[\d*,\/-]+(\s+[\d*,\/-]+){4}$/) {
push @errors, 'SCHEDULE_CRON must be a valid 5-field cron expression';
}
}
my $remotes = $data->{REMOTES} // '';
if ($remotes ne '' && $remotes !~ /^[a-zA-Z0-9_,-]+$/) {
push @errors, 'REMOTES must be comma-separated remote names (letters, digits, hyphens, underscores)';
}
return [grep { $_ ne '' } @errors];
}
# validate_schedule_name($name)
# Returns error string or empty string if valid.
sub validate_schedule_name {
my ($name) = @_;
if (!defined $name || $name eq '') {
return 'Schedule name is required';
}
if ($name !~ /^[a-zA-Z0-9_-]+$/) {
return 'Schedule name may only contain letters, digits, hyphens, and underscores';
}
if (length($name) > 64) {
return 'Schedule name is too long (max 64 characters)';
}
return '';
}
# -- Private helpers --
sub _validate_port {
my ($val) = @_;
$val //= '';
return '' if $val eq '';
if ($val !~ /^\d+$/ || $val < 1 || $val > 65535) {
return 'REMOTE_PORT must be 1-65535';
}
return '';
}
sub _validate_positive_int {
my ($name, $val) = @_;
$val //= '';
return '' if $val eq '';
if ($val !~ /^\d+$/ || $val < 1) {
return "$name must be a positive integer";
}
return '';
}
sub _validate_non_negative_int {
my ($name, $val) = @_;
$val //= '';
return '' if $val eq '';
if ($val !~ /^\d+$/) {
return "$name must be a non-negative integer";
}
return '';
}
1;