Files
Microsoft-Rewards-Bot/src/util/validation/StartupValidator.ts

733 lines
24 KiB
TypeScript

import chalk from 'chalk'
import fs from 'fs'
import path from 'path'
import { Account } from '../../interface/Account'
import { Config } from '../../interface/Config'
import { log } from '../notifications/Logger'
interface ValidationError {
severity: 'error' | 'warning'
category: string
message: string
fix?: string
docsLink?: string
blocking?: boolean // If true, prevents bot startup
}
export class StartupValidator {
private errors: ValidationError[] = []
private warnings: ValidationError[] = []
/**
* Run all validation checks before starting the bot.
* Throws ValidationError if critical (blocking) errors are found.
* Displays errors and warnings to help users fix configuration issues.
*/
async validate(config: Config, accounts: Account[]): Promise<boolean> {
log('main', 'STARTUP', 'Running configuration validation...')
// Run all validation checks
this.validateAccounts(accounts)
this.validateConfig(config)
this.validateEnvironment()
this.validateFileSystem(config)
this.validateBrowserSettings(config)
this.validateNetworkSettings(config)
this.validateWorkerSettings(config)
this.validateExecutionSettings(config)
this.validateSearchSettings(config)
this.validateHumanizationSettings(config)
this.validateSecuritySettings(config)
// Display results (await to respect the delay)
await this.displayResults()
// Check for blocking errors
const blockingErrors = this.errors.filter(e => e.blocking === true)
if (blockingErrors.length > 0) {
const errorMsg = `Validation failed with ${blockingErrors.length} critical error(s). Fix configuration before proceeding.`
log('main', 'VALIDATION', errorMsg, 'error')
throw new Error(errorMsg)
}
// Non-blocking errors and warnings allow execution to continue
return true
}
private validateAccounts(accounts: Account[]): void {
if (!accounts || accounts.length === 0) {
this.addError(
'accounts',
'No accounts found in accounts.json',
'Add at least one account to src/accounts.json or src/accounts.jsonc',
'docs/accounts.md',
true // blocking: no accounts = nothing to run
)
return
}
accounts.forEach((account, index) => {
const prefix = `Account ${index + 1} (${account.email || 'unknown'})`
// Required: email
if (!account.email || typeof account.email !== 'string') {
this.addError(
'accounts',
`${prefix}: Missing or invalid email address`,
'Add a valid email address in the "email" field',
undefined,
true // blocking: email is required
)
} else if (!/@/.test(account.email)) {
this.addError(
'accounts',
`${prefix}: Email format is invalid`,
'Email must contain @ symbol (e.g., user@example.com)',
undefined,
true // blocking: invalid email = cannot login
)
}
// Required: password
if (!account.password || typeof account.password !== 'string') {
this.addError(
'accounts',
`${prefix}: Missing or invalid password`,
'Add your Microsoft account password in the "password" field',
undefined,
true // blocking: password is required
)
} else if (account.password.length < 4) {
this.addWarning(
'accounts',
`${prefix}: Password seems too short (${account.password.length} characters)`,
'Verify this is your correct Microsoft account password'
)
}
// Simplified: only validate recovery email if provided
if (account.recoveryEmail && typeof account.recoveryEmail === 'string' && account.recoveryEmail.trim() !== '') {
if (!/@/.test(account.recoveryEmail)) {
this.addError(
'accounts',
`${prefix}: Recovery email format is invalid`,
'Recovery email must be a valid email address (e.g., backup@gmail.com)'
)
}
} else {
this.addWarning(
'accounts',
`${prefix}: No recovery email configured`,
'Recovery email is optional but recommended for security challenge verification',
'docs/accounts.md'
)
}
// Optional but recommended: TOTP
if (!account.totp || account.totp.trim() === '') {
this.addWarning(
'accounts',
`${prefix}: No TOTP (2FA) secret configured`,
'Highly recommended: Set up 2FA and add your TOTP secret for automated login',
'docs/accounts.md'
)
} else {
const cleaned = account.totp.replace(/\s+/g, '')
if (cleaned.length < 16) {
this.addWarning(
'accounts',
`${prefix}: TOTP secret seems too short (${cleaned.length} chars)`,
'Verify you copied the complete Base32 secret from Microsoft Authenticator setup'
)
}
// Check if it's Base32 (A-Z, 2-7)
if (!/^[A-Z2-7\s]+$/i.test(account.totp)) {
this.addWarning(
'accounts',
`${prefix}: TOTP secret contains invalid characters`,
'TOTP secrets should only contain letters A-Z and numbers 2-7 (Base32 format)'
)
}
}
// Proxy validation
if (account.proxy) {
const hasProxyUrl = account.proxy.url && account.proxy.url.trim() !== ''
const proxyEnabled = account.proxy.proxyAxios === true
if (proxyEnabled && !hasProxyUrl) {
this.addError(
'accounts',
`${prefix}: proxyAxios is true but proxy URL is empty`,
'Set proxyAxios to false if not using a proxy, or provide valid proxy URL/port',
undefined,
true // blocking
)
}
if (hasProxyUrl) {
if (!account.proxy.port || account.proxy.port <= 0) {
this.addError(
'accounts',
`${prefix}: Proxy URL provided but port is missing or invalid`,
'Add a valid proxy port number (e.g., 8080, 3128)'
)
}
}
}
})
}
private validateConfig(config: Config): void {
const maybeSchedule = (config as unknown as { schedule?: unknown }).schedule
if (maybeSchedule !== undefined) {
this.addWarning(
'config',
'Legacy schedule settings detected in config.jsonc.',
'Remove schedule.* entries and use your operating system scheduler.',
'docs/schedule.md'
)
}
// Headless mode in Docker
if (process.env.FORCE_HEADLESS === '1' && config.browser?.headless === false) {
this.addWarning(
'config',
'FORCE_HEADLESS=1 but config.browser.headless is false',
'Docker environment forces headless mode. Your config setting will be overridden.'
)
}
// Parallel mode warning
if (config.parallel === true) {
this.addWarning(
'config',
'Parallel mode enabled (desktop + mobile run simultaneously)',
'This uses more resources. Disable if you experience crashes or timeouts.',
'docs/config.md'
)
}
// Clusters validation
if (config.clusters > 1) {
this.addWarning(
'config',
`Clusters set to ${config.clusters} - accounts will run in parallel`,
'Ensure your system has enough resources (RAM, CPU) for concurrent execution'
)
}
// Global timeout validation
const timeout = typeof config.globalTimeout === 'string'
? config.globalTimeout
: `${config.globalTimeout}ms`
if (timeout === '0' || timeout === '0ms' || timeout === '0s') {
this.addError(
'config',
'Global timeout is set to 0',
'Set a reasonable timeout value (e.g., "30s", "60s") to prevent infinite hangs',
undefined,
true // blocking: 0 timeout = infinite hangs guaranteed
)
}
// Job state validation
if (config.jobState?.enabled === false) {
this.addWarning(
'config',
'Job state tracking is disabled',
'The bot will not save progress. If interrupted, all tasks will restart from scratch.',
'docs/jobstate.md'
)
}
// Risk management validation
if (config.riskManagement?.enabled === true) {
// If risk management is enabled, notify the user to ensure policies are configured.
// This avoids an empty-block lint/compile error and provides actionable guidance.
this.addWarning(
'riskManagement',
'Risk management is enabled but no specific policies were validated here',
'Review and configure riskManagement settings (throttles, maxRestarts, detection thresholds)',
'docs/config.md'
)
}
// Search delays validation
const minDelay = typeof config.searchSettings.searchDelay.min === 'string'
? config.searchSettings.searchDelay.min
: `${config.searchSettings.searchDelay.min}ms`
if (minDelay === '0' || minDelay === '0ms' || minDelay === '0s') {
this.addWarning(
'config',
'Search delay minimum is 0 - this may look suspicious',
'Consider setting a minimum delay (e.g., "1s", "2s") for more natural behavior'
)
}
}
private validateEnvironment(): void {
// Node.js version check
const nodeVersion = process.version
const major = parseInt(nodeVersion.split('.')[0]?.replace('v', '') || '0', 10)
if (major < 18) {
this.addError(
'environment',
`Node.js version ${nodeVersion} is too old`,
'Install Node.js 18 or newer. Visit https://nodejs.org/',
'docs/getting-started.md'
)
} else if (major < 20) {
this.addWarning(
'environment',
`Node.js version ${nodeVersion} is outdated`,
'Consider upgrading to Node.js 20+ for better performance and security'
)
}
// Docker-specific checks
if (process.env.FORCE_HEADLESS === '1') {
this.addWarning(
'environment',
'Running in Docker/containerized environment',
'Make sure volumes are correctly mounted for sessions persistence'
)
}
// Time sync info for TOTP users (informational, not a problem)
if (process.platform === 'linux') {
// This is just informational - not displayed as warning
log('main', 'VALIDATION', '💡 Linux detected: Ensure system time is synchronized for TOTP')
log('main', 'VALIDATION', ' Suggestion: Run: sudo timedatectl set-ntp true (required for TOTP to work correctly)')
}
}
private validateFileSystem(config: Config): void {
// Check if sessions directory exists or can be created
const sessionPath = path.isAbsolute(config.sessionPath)
? config.sessionPath
: path.join(process.cwd(), config.sessionPath)
if (!fs.existsSync(sessionPath)) {
try {
fs.mkdirSync(sessionPath, { recursive: true })
this.addWarning(
'filesystem',
`Created missing sessions directory: ${sessionPath}`,
'Session data will be stored here'
)
} catch (error) {
this.addError(
'filesystem',
`Cannot create sessions directory: ${sessionPath}`,
`Check file permissions. Error: ${error instanceof Error ? error.message : String(error)}`
)
}
}
// Check job-state directory if enabled
if (config.jobState?.enabled !== false) {
const jobStateDir = config.jobState?.dir
? config.jobState.dir
: path.join(sessionPath, 'job-state')
if (!fs.existsSync(jobStateDir)) {
try {
fs.mkdirSync(jobStateDir, { recursive: true })
} catch (error) {
this.addWarning(
'filesystem',
`Cannot create job-state directory: ${jobStateDir}`,
'Job state tracking may fail. Check file permissions.'
)
}
}
}
}
private validateBrowserSettings(config: Config): void {
// Headless validation - only warn in Docker/containerized environments
if (!config.browser?.headless && process.env.FORCE_HEADLESS === '1') {
this.addWarning(
'browser',
'FORCE_HEADLESS=1 but config.browser.headless is false',
'Docker environment forces headless mode. Your config setting will be overridden.',
'docs/docker.md'
)
}
// Fingerprinting validation
if (config.saveFingerprint?.desktop === false && config.saveFingerprint?.mobile === false) {
this.addWarning(
'browser',
'Fingerprint saving is completely disabled',
'Each run will generate new fingerprints, which may look suspicious'
)
}
}
private validateNetworkSettings(config: Config): void {
// Webhook validation
if (config.webhook?.enabled === true) {
if (!config.webhook.url || config.webhook.url.trim() === '') {
this.addError(
'network',
'Webhook enabled but URL is missing',
'Add webhook URL or set webhook.enabled=false',
'docs/config.md',
true // blocking: enabled but no URL = will crash
)
} else if (!config.webhook.url.startsWith('http')) {
this.addError(
'network',
`Invalid webhook URL: ${config.webhook.url}`,
'Webhook URL must start with http:// or https://',
undefined,
true // blocking: invalid URL = will crash
)
}
}
// Conclusion webhook validation
if (config.conclusionWebhook?.enabled === true) {
if (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '') {
this.addError(
'network',
'Conclusion webhook enabled but URL is missing',
'Add conclusion webhook URL or disable it',
undefined,
true // blocking: enabled but no URL = will crash
)
}
}
// NTFY validation
if (config.ntfy?.enabled === true) {
if (!config.ntfy.url || config.ntfy.url.trim() === '') {
this.addError(
'network',
'NTFY enabled but URL is missing',
'Add NTFY server URL or set ntfy.enabled=false',
'docs/ntfy.md',
true // blocking: enabled but no URL = will crash
)
}
if (!config.ntfy.topic || config.ntfy.topic.trim() === '') {
this.addError(
'network',
'NTFY enabled but topic is missing',
'Add NTFY topic name',
'docs/ntfy.md',
true // blocking: enabled but no topic = will crash
)
}
}
}
private validateWorkerSettings(config: Config): void {
const workers = config.workers
// Check if at least one worker is enabled
const anyEnabled = workers.doDailySet || workers.doMorePromotions || workers.doPunchCards ||
workers.doDesktopSearch || workers.doMobileSearch || workers.doDailyCheckIn ||
workers.doReadToEarn
if (!anyEnabled) {
this.addWarning(
'workers',
'All workers are disabled - bot will do nothing',
'Enable at least one worker task (doDailySet, doDesktopSearch, etc.)',
'docs/config.md'
)
}
// Mobile + desktop search check
if (!workers.doDesktopSearch && !workers.doMobileSearch) {
this.addWarning(
'workers',
'Both desktop and mobile searches are disabled',
'Enable at least one search type to earn search points'
)
}
// Bundle validation
if (workers.bundleDailySetWithSearch === true && !workers.doDesktopSearch) {
this.addWarning(
'workers',
'bundleDailySetWithSearch is enabled but doDesktopSearch is disabled',
'Desktop search will not run after Daily Set. Enable doDesktopSearch or disable bundling.'
)
}
}
private validateExecutionSettings(config: Config): void {
// Validate passesPerRun
const passes = config.passesPerRun ?? 1
if (passes < 1) {
this.addError(
'execution',
'passesPerRun must be at least 1',
'Set passesPerRun to 1 or higher in config.jsonc',
undefined,
true
)
}
if (passes > 5) {
this.addWarning(
'execution',
`passesPerRun is set to ${passes} (very high)`,
'Running multiple passes per day may trigger Microsoft detection. Recommended: 1-2 passes max',
'docs/config-reference.md'
)
}
if (passes > 1) {
// This is intentional behavior confirmation, not a warning
log('main', 'VALIDATION', `✓ [OK] passesPerRun = ${passes}: Job-state skip is disabled (intentional)`)
log('main', 'VALIDATION', ' Suggestion: All accounts will run on every pass, even if already completed. This is intentional for multiple passes.')
log('main', 'VALIDATION', ' Docs: docs/jobstate.md')
}
// Validate clusters
if (config.clusters < 1) {
this.addError(
'execution',
'clusters must be at least 1',
'Set clusters to 1 or higher in config.jsonc',
undefined,
true
)
}
if (config.clusters > 10) {
this.addWarning(
'execution',
`clusters is set to ${config.clusters} (very high)`,
'Too many clusters may cause resource exhaustion. Recommended: 1-4 clusters'
)
}
}
private validateSearchSettings(config: Config): void {
const search = config.searchSettings
// Retry validation
if (search.retryMobileSearchAmount < 0) {
this.addWarning(
'search',
'retryMobileSearchAmount is negative',
'Set to 0 or positive number (recommended: 2-3)'
)
}
if (search.retryMobileSearchAmount > 10) {
this.addWarning(
'search',
`retryMobileSearchAmount is very high (${search.retryMobileSearchAmount})`,
'High retry count may trigger detection. Recommended: 2-3'
)
}
// Fallback validation
if (search.localFallbackCount !== undefined && search.localFallbackCount < 10) {
this.addWarning(
'search',
`localFallbackCount is low (${search.localFallbackCount})`,
'Consider at least 15-25 fallback queries for variety'
)
}
// Query diversity check
if (config.queryDiversity?.enabled === false && !config.searchOnBingLocalQueries) {
this.addWarning(
'search',
'Query diversity disabled and local queries disabled',
'Bot will only use Google Trends. Enable one query source for better variety.',
'docs/config.md'
)
}
}
private validateHumanizationSettings(config: Config): void {
const human = config.humanization
if (!human || human.enabled === false) {
this.addWarning(
'humanization',
'Humanization is completely disabled',
'This increases detection risk. Consider enabling for safer automation.',
'docs/config.md'
)
return
}
// Gesture probabilities
if (human.gestureMoveProb !== undefined) {
if (human.gestureMoveProb < 0 || human.gestureMoveProb > 1) {
this.addError(
'humanization',
`gestureMoveProb must be between 0 and 1 (got ${human.gestureMoveProb})`,
'Set a probability value between 0.0 and 1.0'
)
}
if (human.gestureMoveProb === 0) {
this.addWarning(
'humanization',
'Mouse gestures disabled (gestureMoveProb=0)',
'This may look robotic. Consider 0.3-0.7 for natural behavior.'
)
}
}
if (human.gestureScrollProb !== undefined) {
if (human.gestureScrollProb < 0 || human.gestureScrollProb > 1) {
this.addError(
'humanization',
`gestureScrollProb must be between 0 and 1 (got ${human.gestureScrollProb})`,
'Set a probability value between 0.0 and 1.0'
)
}
}
// Action delays
if (human.actionDelay) {
const minMs = typeof human.actionDelay.min === 'string'
? parseInt(human.actionDelay.min, 10)
: human.actionDelay.min
const maxMs = typeof human.actionDelay.max === 'string'
? parseInt(human.actionDelay.max, 10)
: human.actionDelay.max
if (minMs > maxMs) {
this.addError(
'humanization',
'actionDelay min is greater than max',
`Fix: min=${minMs} should be <= max=${maxMs}`
)
}
}
// Random off days
if (human.randomOffDaysPerWeek !== undefined) {
if (human.randomOffDaysPerWeek < 0 || human.randomOffDaysPerWeek > 7) {
this.addError(
'humanization',
`randomOffDaysPerWeek must be 0-7 (got ${human.randomOffDaysPerWeek})`,
'Set to a value between 0 (no off days) and 7 (always off)'
)
}
}
// Allowed windows validation
if (human.allowedWindows && Array.isArray(human.allowedWindows)) {
human.allowedWindows.forEach((window, idx) => {
if (typeof window !== 'string') {
this.addError(
'humanization',
`allowedWindows[${idx}] is not a string`,
'Format: "HH:mm-HH:mm" (e.g., "09:00-17:00")'
)
} else if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) {
this.addWarning(
'humanization',
`allowedWindows[${idx}] format may be invalid: "${window}"`,
'Expected format: "HH:mm-HH:mm" (24-hour, e.g., "09:00-17:00")'
)
}
})
}
}
private validateSecuritySettings(config: Config): void {
// Check logging redaction
const logging = config.logging as { redactEmails?: boolean } | undefined
if (logging && logging.redactEmails === false) {
this.addWarning(
'security',
'Email redaction is disabled in logs',
'Enable redactEmails=true if you share logs publicly',
'docs/security.md'
)
}
// Proxy exposure check
if (config.proxy?.proxyGoogleTrends === false && config.proxy?.proxyBingTerms === false) {
this.addWarning(
'security',
'All external API calls will use your real IP',
'Consider enabling proxy for Google Trends or Bing Terms to mask your IP'
)
}
// Crash recovery
if (config.crashRecovery?.autoRestart === true) {
const maxRestarts = config.crashRecovery.maxRestarts ?? 2
if (maxRestarts > 5) {
this.addWarning(
'security',
`Crash recovery maxRestarts is high (${maxRestarts})`,
'Excessive restarts on errors may trigger rate limits or detection'
)
}
}
}
private addError(category: string, message: string, fix?: string, docsLink?: string, blocking = false): void {
this.errors.push({ severity: 'error', category, message, fix, docsLink, blocking })
}
private addWarning(category: string, message: string, fix?: string, docsLink?: string): void {
this.warnings.push({ severity: 'warning', category, message, fix, docsLink, blocking: false })
}
private async displayResults(): Promise<void> {
if (this.errors.length > 0) {
log('main', 'VALIDATION', chalk.red('❌ VALIDATION ERRORS FOUND:'), 'error')
this.errors.forEach((err, index) => {
log('main', 'VALIDATION', chalk.red(`${index + 1}. [${err.category.toUpperCase()}] ${err.message}`), 'error')
if (err.fix) {
log('main', 'VALIDATION', chalk.yellow(` Fix: ${err.fix}`), 'warn')
}
if (err.docsLink) {
log('main', 'VALIDATION', ` Docs: ${err.docsLink}`)
}
})
}
if (this.warnings.length > 0) {
log('main', 'VALIDATION', chalk.yellow('⚠️ WARNINGS:'), 'warn')
this.warnings.forEach((warn, index) => {
log('main', 'VALIDATION', chalk.yellow(`${index + 1}. [${warn.category.toUpperCase()}] ${warn.message}`), 'warn')
if (warn.fix) {
log('main', 'VALIDATION', ` Suggestion: ${warn.fix}`)
}
if (warn.docsLink) {
log('main', 'VALIDATION', ` Docs: ${warn.docsLink}`)
}
})
}
if (this.errors.length === 0 && this.warnings.length === 0) {
log('main', 'VALIDATION', chalk.green('✅ All validation checks passed!'))
} else {
const errorLabel = this.errors.length === 1 ? 'error' : 'errors'
const warningLabel = this.warnings.length === 1 ? 'warning' : 'warnings'
log('main', 'VALIDATION', `[${this.errors.length > 0 ? 'ERROR' : 'OK'}] Found: ${this.errors.length} ${errorLabel} | ${this.warnings.length} ${warningLabel}`)
if (this.errors.length > 0) {
log('main', 'VALIDATION', 'Bot will continue, but issues may cause failures', 'warn')
log('main', 'VALIDATION', 'Full documentation: docs/index.md')
await new Promise(resolve => setTimeout(resolve, 3000))
} else if (this.warnings.length > 0) {
log('main', 'VALIDATION', 'Warnings detected - review recommended', 'warn')
await new Promise(resolve => setTimeout(resolve, 2000))
}
}
}
}