package Gniza4cpWHM::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"; } } } if (defined $data->{USER_RESTORE_REMOTES} && $data->{USER_RESTORE_REMOTES} ne '') { unless ($data->{USER_RESTORE_REMOTES} eq 'all' || $data->{USER_RESTORE_REMOTES} =~ /^[a-zA-Z0-9_,-]+$/) { push @errors, 'USER_RESTORE_REMOTES must be "all" or comma-separated remote names'; } } # 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;