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(` 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)')
|
||||||
|
|||||||
@@ -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