Full-featured cPanel backup tool with SSH, S3, and Google Drive support. Includes WHM plugin with Tailwind/DaisyUI UI, multi-remote management, decoupled schedules, and account restore workflows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
9.9 KiB
Perl
285 lines
9.9 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';
|
|
}
|
|
}
|
|
|
|
# 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;
|