mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -25,6 +25,16 @@ export async function handleAuthCommand(args: string[]): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,10 @@ async function promptForToken(): Promise<string> {
|
||||
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: '))
|
||||
|
||||
+21
-11
@@ -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
|
||||
|
||||
+24
-1
@@ -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)')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user