From a455cd8297ab9cd718718fc2aa7bfd265b325599 Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 23 Dec 2025 23:15:54 +0800 Subject: [PATCH] feat: add automatic CLI_API_TOKEN generation for the server Implement secure auto-generation of CLI_API_TOKEN to eliminate mandatory environment variable requirement. Token is generated once and persisted to ~/.hapi/settings.json for use by CLI on the same machine. Changes: - New cliApiToken.ts module with secure 256-bit token generation - Configuration now accepts token from env, file, or generates on-demand - Server displays prominently on first run, saves to settings - CLI auth status command provides discovery hints for all scenarios - Settings file parse errors fail fast to prevent data loss --- cli/src/commands/auth.ts | 10 +++ cli/src/ui/tokenInit.ts | 5 +- server/src/configuration.ts | 32 ++++++--- server/src/index.ts | 25 ++++++- server/src/web/cliApiToken.ts | 124 ++++++++++++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 server/src/web/cliApiToken.ts diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts index 3ab531d1..fb30d369 100644 --- a/cli/src/commands/auth.ts +++ b/cli/src/commands/auth.ts @@ -25,6 +25,16 @@ export async function handleAuthCommand(args: string[]): Promise { console.log(chalk.gray(` Token Source: ${tokenSource}`)) console.log(chalk.gray(` Machine ID: ${settings.machineId ?? 'not set'}`)) console.log(chalk.gray(` Host: ${os.hostname()}`)) + + if (!hasToken) { + console.log('') + console.log(chalk.yellow(' Token not configured. To get your token:')) + console.log(chalk.gray(' 1. Check the server startup logs (first run shows generated token)')) + console.log(chalk.gray(' 2. Read ~/.hapi/settings.json on the server')) + console.log(chalk.gray(' 3. Ask your server administrator (if token is set via env var)')) + console.log('') + console.log(chalk.gray(' Then run: hapi auth login')) + } return } diff --git a/cli/src/ui/tokenInit.ts b/cli/src/ui/tokenInit.ts index 0a7d6aa8..bd0daf90 100644 --- a/cli/src/ui/tokenInit.ts +++ b/cli/src/ui/tokenInit.ts @@ -50,7 +50,10 @@ async function promptForToken(): Promise { const rl = readline.createInterface({ input, output }) console.log(chalk.yellow('\nNo CLI_API_TOKEN found.')) - console.log(chalk.gray('You can set it via environment variable or enter it now.\n')) + console.log(chalk.gray('Where to find the token:')) + console.log(chalk.gray(' 1. Check the server startup logs (first run shows generated token)')) + console.log(chalk.gray(' 2. Read ~/.hapi/settings.json on the server')) + console.log(chalk.gray(' 3. Ask your server administrator (if token is set via env var)\n')) try { const token = await rl.question(chalk.cyan('Enter CLI_API_TOKEN: ')) diff --git a/server/src/configuration.ts b/server/src/configuration.ts index b953a88a..6a8fdf03 100644 --- a/server/src/configuration.ts +++ b/server/src/configuration.ts @@ -1,10 +1,8 @@ /** * Configuration for hapi-server (Direct Connect) * - * Required environment variables: - * - CLI_API_TOKEN: Shared secret for hapi CLI authentication - * - * Optional Telegram environment variables: + * Optional environment variables: + * - CLI_API_TOKEN: Shared secret for hapi CLI authentication (auto-generated if not set) * - TELEGRAM_BOT_TOKEN: Telegram Bot API token from @BotFather * - ALLOWED_CHAT_IDS: Comma-separated list of allowed Telegram chat IDs */ @@ -24,7 +22,13 @@ class Configuration { public readonly telegramEnabled: boolean /** CLI auth token (shared secret) */ - public readonly cliApiToken: string + public cliApiToken: string + + /** Source of CLI API token ('pending' | 'env' | 'file' | 'generated') */ + public cliApiTokenSource: string + + /** Path to settings.json file */ + public readonly settingsFile: string /** Data directory for credentials and state */ public readonly dataDir: string @@ -56,12 +60,9 @@ class Configuration { .filter(id => !isNaN(id)) : [] - // Required: CLI API token (shared secret) - const cliApiToken = process.env.CLI_API_TOKEN - if (!cliApiToken) { - throw new Error('CLI_API_TOKEN environment variable is required') - } - this.cliApiToken = cliApiToken + // CLI API token - will be set later by getOrCreateCliApiToken() + this.cliApiToken = '' + this.cliApiTokenSource = 'pending' // Mini App web server configuration const webappPortRaw = process.env.WEBAPP_PORT @@ -123,6 +124,9 @@ class Configuration { this.dbPath = join(this.dataDir, 'hapi.db') } + // Settings file path + this.settingsFile = join(this.dataDir, 'settings.json') + // Ensure data directory exists if (!existsSync(this.dataDir)) { mkdirSync(this.dataDir, { recursive: true }) @@ -133,6 +137,12 @@ class Configuration { isChatIdAllowed(chatId: number): boolean { return this.allowedChatIds.includes(chatId) } + + /** Set CLI API token (called after async initialization) */ + _setCliApiToken(token: string, source: string): void { + this.cliApiToken = token + this.cliApiTokenSource = source + } } // Lazy initialization to allow configuration to fail gracefully diff --git a/server/src/index.ts b/server/src/index.ts index afc7bb7e..e06a5dac 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import { SyncEngine, type SyncEvent } from './sync/syncEngine' import { HappyBot } from './telegram/bot' import { startWebServer } from './web/server' import { getOrCreateJwtSecret } from './web/jwtSecret' +import { getOrCreateCliApiToken } from './web/cliApiToken' import { createSocketServer } from './socket/server' import { SSEManager } from './sse/sseManager' import type { Server as BunServer } from 'bun' @@ -27,8 +28,30 @@ let sseManager: SSEManager | null = null async function main() { console.log('HAPI Server starting...') - // Load configuration (will throw if required env vars missing) + // Load configuration const config = getConfiguration() + + // Initialize CLI API token + const tokenResult = await getOrCreateCliApiToken(config.dataDir) + config._setCliApiToken(tokenResult.token, tokenResult.source) + + // Display token information + if (tokenResult.isNew) { + console.log('') + console.log('='.repeat(70)) + console.log(' NEW CLI_API_TOKEN GENERATED') + console.log('='.repeat(70)) + console.log('') + console.log(` Token: ${tokenResult.token}`) + console.log('') + console.log(` Saved to: ${tokenResult.filePath}`) + console.log('') + console.log('='.repeat(70)) + console.log('') + } else { + console.log(`[Server] CLI_API_TOKEN: loaded from ${tokenResult.source}`) + } + console.log(`[Server] Mini App: ${config.miniAppUrl} (port ${config.webappPort})`) if (!config.telegramEnabled) { console.log('[Server] Telegram: disabled (missing TELEGRAM_BOT_TOKEN)') diff --git a/server/src/web/cliApiToken.ts b/server/src/web/cliApiToken.ts new file mode 100644 index 00000000..ccb2f072 --- /dev/null +++ b/server/src/web/cliApiToken.ts @@ -0,0 +1,124 @@ +/** + * CLI API Token management + * + * Handles automatic generation and persistence of CLI_API_TOKEN. + * Priority: environment variable > settings.json > auto-generate + */ + +import { existsSync } from 'node:fs' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' +import { dirname, join } from 'node:path' + +interface Settings { + onboardingCompleted?: boolean + machineId?: string + machineIdConfirmedByServer?: boolean + daemonAutoStartWhenRunningHappy?: boolean + cliApiToken?: string +} + +export interface CliApiTokenResult { + token: string + source: 'env' | 'file' | 'generated' + isNew: boolean + filePath: string +} + +/** + * Generate a cryptographically secure random token + * 32 bytes = 256 bits, base64url encoded = ~43 characters + */ +function generateSecureToken(): string { + return randomBytes(32).toString('base64url') +} + +/** + * Check if a token appears to be weak + * Only applies to user-provided tokens (environment variable) + */ +function isWeakToken(token: string): boolean { + if (token.length < 16) return true + + // Detect common weak patterns + const weakPatterns = [ + /^[0-9]+$/, // Pure numbers + /^(.)\1+$/, // Repeated character + /^(abc|123|password|secret|token)/i, // Common prefixes + ] + return weakPatterns.some(p => p.test(token)) +} + +/** + * Read settings from file, preserving all existing fields. + * Returns null if file exists but cannot be parsed (to avoid data loss). + */ +async function readSettings(settingsFile: string): Promise { + if (!existsSync(settingsFile)) { + return {} + } + try { + const content = await readFile(settingsFile, 'utf8') + return JSON.parse(content) + } catch (error) { + // Return null to signal parse error - caller should not overwrite + console.error(`[WARN] Failed to parse ${settingsFile}: ${error}`) + return null + } +} + +/** + * Write settings to file atomically (temp file + rename) + */ +async function writeSettings(settingsFile: string, settings: Settings): Promise { + const dir = dirname(settingsFile) + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true, mode: 0o700 }) + } + + const tmpFile = settingsFile + '.tmp' + await writeFile(tmpFile, JSON.stringify(settings, null, 2)) + await rename(tmpFile, settingsFile) +} + +/** + * Get or create CLI API token + * + * Priority: + * 1. CLI_API_TOKEN environment variable (highest - backward compatible) + * 2. settings.json cliApiToken field + * 3. Auto-generate and save to settings.json + */ +export async function getOrCreateCliApiToken(dataDir: string): Promise { + const settingsFile = join(dataDir, 'settings.json') + + // 1. Environment variable has highest priority (backward compatible) + const envToken = process.env.CLI_API_TOKEN + if (envToken) { + if (isWeakToken(envToken)) { + console.warn('[WARN] CLI_API_TOKEN appears to be weak. Consider using a stronger secret.') + } + return { token: envToken, source: 'env', isNew: false, filePath: settingsFile } + } + + // 2. Read from settings file + const settings = await readSettings(settingsFile) + + // If settings file exists but couldn't be parsed, fail fast to avoid data loss + if (settings === null) { + throw new Error( + `Cannot read ${settingsFile}. Please fix or remove the file and restart.` + ) + } + + if (settings.cliApiToken) { + return { token: settings.cliApiToken, source: 'file', isNew: false, filePath: settingsFile } + } + + // 3. Generate new token and save + const newToken = generateSecureToken() + settings.cliApiToken = newToken + await writeSettings(settingsFile, settings) + + return { token: newToken, source: 'generated', isNew: true, filePath: settingsFile } +}