Files
hapi/server/src/index.ts
T
weishu e8137cbeb2 feat: add TLS certificate validation for tunnel access
Add tlsGate module to validate tunnel TLS certificates before announcing public access. This ensures the tunnel has a valid, trusted certificate before displaying the public URL to users. The validation checks certificate validity period, hostname matching (including wildcard support), and handles both DNS names and IP addresses.

Refactor tunnel access announcement to wait for TLS readiness asynchronously, preventing premature display of access links.
2026-01-13 21:25:56 +08:00

266 lines
9.5 KiB
TypeScript

/**
* HAPI Server - Main Entry Point
*
* Provides:
* - Web app + HTTP API
* - Socket.IO for CLI connections
* - SSE updates for the web UI
* - Optional Telegram bot for notifications and Mini App entrypoint
*/
import { createConfiguration, type ConfigSource } from './configuration'
import { Store } from './store'
import { SyncEngine, type SyncEvent } from './sync/syncEngine'
import { NotificationHub } from './notifications/notificationHub'
import type { NotificationChannel } from './notifications/notificationTypes'
import { HappyBot } from './telegram/bot'
import { startWebServer } from './web/server'
import { getOrCreateJwtSecret } from './config/jwtSecret'
import { createSocketServer } from './socket/server'
import { SSEManager } from './sse/sseManager'
import { getOrCreateVapidKeys } from './config/vapidKeys'
import { PushService } from './push/pushService'
import { PushNotificationChannel } from './push/pushNotificationChannel'
import { VisibilityTracker } from './visibility/visibilityTracker'
import { TunnelManager } from './tunnel'
import { waitForTunnelTlsReady } from './tunnel/tlsGate'
import QRCode from 'qrcode'
import type { Server as BunServer } from 'bun'
import type { WebSocketData } from '@socket.io/bun-engine'
/** Format config source for logging */
function formatSource(source: ConfigSource | 'generated'): string {
switch (source) {
case 'env':
return 'environment'
case 'file':
return 'settings.json'
case 'default':
return 'default'
case 'generated':
return 'generated'
}
}
type RelayFlagSource = 'default' | '--relay' | '--no-relay'
function resolveRelayFlag(args: string[]): { enabled: boolean; source: RelayFlagSource } {
let enabled = false
let source: RelayFlagSource = 'default'
for (const arg of args) {
if (arg === '--relay') {
enabled = true
source = '--relay'
} else if (arg === '--no-relay') {
enabled = false
source = '--no-relay'
}
}
return { enabled, source }
}
let syncEngine: SyncEngine | null = null
let happyBot: HappyBot | null = null
let webServer: BunServer<WebSocketData> | null = null
let sseManager: SSEManager | null = null
let visibilityTracker: VisibilityTracker | null = null
let notificationHub: NotificationHub | null = null
let tunnelManager: TunnelManager | null = null
async function main() {
console.log('HAPI Server starting...')
// Load configuration (async - loads from env/file with persistence)
const config = await createConfiguration()
const relayApiDomain = process.env.HAPI_RELAY_API || 'relay.hapi.run'
const relayFlag = resolveRelayFlag(process.argv)
// Display CLI API token information
if (config.cliApiTokenIsNew) {
console.log('')
console.log('='.repeat(70))
console.log(' NEW CLI_API_TOKEN GENERATED')
console.log('='.repeat(70))
console.log('')
console.log(` Token: ${config.cliApiToken}`)
console.log('')
console.log(` Saved to: ${config.settingsFile}`)
console.log('')
console.log('='.repeat(70))
console.log('')
} else {
console.log(`[Server] CLI_API_TOKEN: loaded from ${formatSource(config.sources.cliApiToken)}`)
}
// Display other configuration sources
console.log(`[Server] WEBAPP_HOST: ${config.webappHost} (${formatSource(config.sources.webappHost)})`)
console.log(`[Server] WEBAPP_PORT: ${config.webappPort} (${formatSource(config.sources.webappPort)})`)
console.log(`[Server] WEBAPP_URL: ${config.miniAppUrl} (${formatSource(config.sources.webappUrl)})`)
if (!config.telegramEnabled) {
console.log('[Server] Telegram: disabled (no TELEGRAM_BOT_TOKEN)')
} else {
const tokenSource = formatSource(config.sources.telegramBotToken)
console.log(`[Server] Telegram: enabled (${tokenSource})`)
const notificationSource = formatSource(config.sources.telegramNotification)
console.log(`[Server] Telegram notifications: ${config.telegramNotification ? 'enabled' : 'disabled'} (${notificationSource})`)
}
// Display tunnel status
if (relayFlag.enabled) {
console.log(`[Server] Tunnel: enabled (${relayFlag.source}), API: ${relayApiDomain}`)
} else {
console.log(`[Server] Tunnel: disabled (${relayFlag.source})`)
}
const store = new Store(config.dbPath)
const jwtSecret = await getOrCreateJwtSecret()
const vapidKeys = await getOrCreateVapidKeys(config.dataDir)
const vapidSubject = process.env.VAPID_SUBJECT ?? 'mailto:admin@hapi.run'
const pushService = new PushService(vapidKeys, vapidSubject, store)
visibilityTracker = new VisibilityTracker()
sseManager = new SSEManager(30_000, visibilityTracker)
const socketServer = createSocketServer({
store,
jwtSecret,
getSession: (sessionId) => syncEngine?.getSession(sessionId) ?? store.sessions.getSession(sessionId),
onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event),
onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload),
onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload),
onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload)
})
syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager)
const notificationChannels: NotificationChannel[] = [
new PushNotificationChannel(pushService, sseManager, visibilityTracker, config.miniAppUrl)
]
// Initialize Telegram bot (optional)
if (config.telegramEnabled && config.telegramBotToken) {
happyBot = new HappyBot({
syncEngine,
botToken: config.telegramBotToken,
miniAppUrl: config.miniAppUrl,
store
})
// Only add to notification channels if notifications are enabled
if (config.telegramNotification) {
notificationChannels.push(happyBot)
}
}
notificationHub = new NotificationHub(syncEngine, notificationChannels)
// Start HTTP server first (before tunnel, so tunnel has something to forward to)
webServer = await startWebServer({
getSyncEngine: () => syncEngine,
getSseManager: () => sseManager,
getVisibilityTracker: () => visibilityTracker,
jwtSecret,
store,
vapidPublicKey: vapidKeys.publicKey,
socketEngine: socketServer.engine
})
// Start the bot if configured
if (happyBot) {
await happyBot.start()
}
console.log('')
console.log('[Web] Server listening on :' + config.webappPort)
console.log('[Web] Local: http://localhost:' + config.webappPort)
// Initialize tunnel AFTER web server is ready
let tunnelUrl: string | null = null
if (relayFlag.enabled) {
tunnelManager = new TunnelManager({
localPort: config.webappPort,
enabled: true,
apiDomain: relayApiDomain,
authKey: process.env.HAPI_RELAY_AUTH || null,
useRelay: process.env.HAPI_RELAY_FORCE_TCP === 'true' || process.env.HAPI_RELAY_FORCE_TCP === '1'
})
try {
tunnelUrl = await tunnelManager.start()
} catch (error) {
console.error('[Tunnel] Failed to start:', error instanceof Error ? error.message : error)
console.log('[Tunnel] Server continuing without tunnel. Restart without --relay to disable.')
}
}
if (tunnelUrl && tunnelManager) {
const manager = tunnelManager
const announceTunnelAccess = async () => {
const tlsReady = await waitForTunnelTlsReady(tunnelUrl, manager)
if (!tlsReady) {
console.log('[Tunnel] Tunnel stopped before TLS was ready.')
return
}
console.log('[Web] Public: ' + tunnelUrl)
// Generate direct access link with server and token
const officialWebUrl = process.env.HAPI_OFFICIAL_WEB_URL || 'https://app.hapi.run'
const params = new URLSearchParams({
server: tunnelUrl,
token: config.cliApiToken
})
const directAccessUrl = `${officialWebUrl}/?${params.toString()}`
console.log('')
console.log('Open in browser:')
console.log(` ${directAccessUrl}`)
console.log('')
console.log('or scan the QR code to open:')
// Display QR code for easy mobile access
try {
const qrString = await QRCode.toString(directAccessUrl, {
type: 'terminal',
small: true,
margin: 1,
errorCorrectionLevel: 'L'
})
console.log('')
console.log(qrString)
} catch {
// QR code generation failure should not affect main flow
}
}
void announceTunnelAccess()
}
console.log('')
console.log('HAPI Server is ready!')
// Handle shutdown
const shutdown = async () => {
console.log('\nShutting down...')
await tunnelManager?.stop()
await happyBot?.stop()
notificationHub?.stop()
syncEngine?.stop()
sseManager?.stop()
webServer?.stop()
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
// Keep process running
await new Promise(() => {})
}
main().catch((error) => {
console.error('Fatal error:', error)
process.exit(1)
})