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
This commit is contained in:
weishu
2025-12-23 23:16:35 +08:00
parent 706814653e
commit a455cd8297
5 changed files with 183 additions and 13 deletions
+10
View File
@@ -25,6 +25,16 @@ export async function handleAuthCommand(args: string[]): Promise<void> {
console.log(chalk.gray(` Token Source: ${tokenSource}`)) console.log(chalk.gray(` Token Source: ${tokenSource}`))
console.log(chalk.gray(` Machine ID: ${settings.machineId ?? 'not set'}`)) console.log(chalk.gray(` Machine ID: ${settings.machineId ?? 'not set'}`))
console.log(chalk.gray(` Host: ${os.hostname()}`)) 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 return
} }
+4 -1
View File
@@ -50,7 +50,10 @@ async function promptForToken(): Promise<string> {
const rl = readline.createInterface({ input, output }) const rl = readline.createInterface({ input, output })
console.log(chalk.yellow('\nNo CLI_API_TOKEN found.')) 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 { try {
const token = await rl.question(chalk.cyan('Enter CLI_API_TOKEN: ')) const token = await rl.question(chalk.cyan('Enter CLI_API_TOKEN: '))
+21 -11
View File
@@ -1,10 +1,8 @@
/** /**
* Configuration for hapi-server (Direct Connect) * Configuration for hapi-server (Direct Connect)
* *
* Required environment variables: * Optional environment variables:
* - CLI_API_TOKEN: Shared secret for hapi CLI authentication * - CLI_API_TOKEN: Shared secret for hapi CLI authentication (auto-generated if not set)
*
* Optional Telegram environment variables:
* - TELEGRAM_BOT_TOKEN: Telegram Bot API token from @BotFather * - TELEGRAM_BOT_TOKEN: Telegram Bot API token from @BotFather
* - ALLOWED_CHAT_IDS: Comma-separated list of allowed Telegram chat IDs * - ALLOWED_CHAT_IDS: Comma-separated list of allowed Telegram chat IDs
*/ */
@@ -24,7 +22,13 @@ class Configuration {
public readonly telegramEnabled: boolean public readonly telegramEnabled: boolean
/** CLI auth token (shared secret) */ /** 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 */ /** Data directory for credentials and state */
public readonly dataDir: string public readonly dataDir: string
@@ -56,12 +60,9 @@ class Configuration {
.filter(id => !isNaN(id)) .filter(id => !isNaN(id))
: [] : []
// Required: CLI API token (shared secret) // CLI API token - will be set later by getOrCreateCliApiToken()
const cliApiToken = process.env.CLI_API_TOKEN this.cliApiToken = ''
if (!cliApiToken) { this.cliApiTokenSource = 'pending'
throw new Error('CLI_API_TOKEN environment variable is required')
}
this.cliApiToken = cliApiToken
// Mini App web server configuration // Mini App web server configuration
const webappPortRaw = process.env.WEBAPP_PORT const webappPortRaw = process.env.WEBAPP_PORT
@@ -123,6 +124,9 @@ class Configuration {
this.dbPath = join(this.dataDir, 'hapi.db') this.dbPath = join(this.dataDir, 'hapi.db')
} }
// Settings file path
this.settingsFile = join(this.dataDir, 'settings.json')
// Ensure data directory exists // Ensure data directory exists
if (!existsSync(this.dataDir)) { if (!existsSync(this.dataDir)) {
mkdirSync(this.dataDir, { recursive: true }) mkdirSync(this.dataDir, { recursive: true })
@@ -133,6 +137,12 @@ class Configuration {
isChatIdAllowed(chatId: number): boolean { isChatIdAllowed(chatId: number): boolean {
return this.allowedChatIds.includes(chatId) 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 // Lazy initialization to allow configuration to fail gracefully
+24 -1
View File
@@ -14,6 +14,7 @@ import { SyncEngine, type SyncEvent } from './sync/syncEngine'
import { HappyBot } from './telegram/bot' import { HappyBot } from './telegram/bot'
import { startWebServer } from './web/server' import { startWebServer } from './web/server'
import { getOrCreateJwtSecret } from './web/jwtSecret' import { getOrCreateJwtSecret } from './web/jwtSecret'
import { getOrCreateCliApiToken } from './web/cliApiToken'
import { createSocketServer } from './socket/server' import { createSocketServer } from './socket/server'
import { SSEManager } from './sse/sseManager' import { SSEManager } from './sse/sseManager'
import type { Server as BunServer } from 'bun' import type { Server as BunServer } from 'bun'
@@ -27,8 +28,30 @@ let sseManager: SSEManager | null = null
async function main() { async function main() {
console.log('HAPI Server starting...') console.log('HAPI Server starting...')
// Load configuration (will throw if required env vars missing) // Load configuration
const config = getConfiguration() 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})`) console.log(`[Server] Mini App: ${config.miniAppUrl} (port ${config.webappPort})`)
if (!config.telegramEnabled) { if (!config.telegramEnabled) {
console.log('[Server] Telegram: disabled (missing TELEGRAM_BOT_TOKEN)') console.log('[Server] Telegram: disabled (missing TELEGRAM_BOT_TOKEN)')
+124
View File
@@ -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<Settings | null> {
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<void> {
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<CliApiTokenResult> {
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 }
}