Files
hapi/hub/src/configuration.ts
T
weishu b67f4e56e5 feat(hub): per-hub relay auth keys with automatic recovery
The public relay used to accept a shared auth key compiled into every
hub, so its bandwidth was open to anyone. The relay now issues a
per-hub credential it can meter and revoke, and hubs obtain one on
their own.

- --relay resolves an auth key at startup: HAPI_RELAY_AUTH env, then a
  key persisted in settings.json, then a fresh key from the relay's
  /issue endpoint. There is no shared-key fallback; if no key can be
  obtained the tunnel does not start and the hub says why.
- A persisted key rejected by the relay (HTTP 403 after revocation or a
  secret rotation) is discarded and replaced once, then the tunnel is
  restarted, so a revoked hub recovers without manual edits. Keys given
  explicitly through the environment are never overwritten.
- Issuance is rate-limited per public IP; HTTP 429 is reported with the
  retry hint instead of being retried blindly, which matters for users
  sharing a CGNAT or corporate egress address.
- The tunnel URL now comes from upstream tunwg's slog JSON on stderr
  (msg="listener started"), replacing the fork's custom --json event,
  and --log_level=0 keeps per-request logs out of the hub console.

Requires a relay running tunwg with TUNWG_AUTH_SECRET configured.
2026-08-04 08:18:17 +08:00

207 lines
7.4 KiB
TypeScript

/**
* Configuration for hapi-hub (Direct Connect)
*
* Configuration is loaded with priority: environment variable > settings.json > default
* When values are read from environment variables and not present in settings.json,
* they are automatically saved for future use
*
* 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
* - TELEGRAM_NOTIFICATION: Enable/disable Telegram notifications (default: true)
* - SERVERCHAN_SENDKEY: Server酱 SendKey/AppKey for push notifications
* - SERVERCHAN_NOTIFICATION: Enable/disable Server酱 notifications (default: true)
* - HAPI_LISTEN_HOST: Host/IP to bind the HTTP service (default: 127.0.0.1)
* - HAPI_LISTEN_PORT: Port for HTTP service (default: 3006)
* - HAPI_PUBLIC_URL: Public URL for external access (e.g., Telegram Mini App)
* - CORS_ORIGINS: Comma-separated CORS origins
* - HAPI_RELAY_API: Relay API domain for tunwg (default: relay.hapi.run)
* - HAPI_RELAY_AUTH: Relay auth key override (default: per-hub key issued by the relay)
* - HAPI_RELAY_FORCE_TCP: Force TCP relay mode when UDP is unavailable (true/1)
* - VAPID_SUBJECT: Contact email or URL for Web Push (defaults to mailto:admin@hapi.run)
* - HAPI_HOME: Data directory (default: ~/.hapi)
* - DB_PATH: SQLite database path (default: {HAPI_HOME}/hapi.db)
*/
import { existsSync, mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { getOrCreateCliApiToken } from './config/cliApiToken'
import { getSettingsFile } from './config/settings'
import { loadServerSettings, type ServerSettings, type ServerSettingsResult } from './config/serverSettings'
export type ConfigSource = 'env' | 'file' | 'default'
export interface ConfigSources {
telegramBotToken: ConfigSource
telegramNotification: ConfigSource
serverChanSendKey: ConfigSource
serverChanNotification: ConfigSource
listenHost: ConfigSource
listenPort: ConfigSource
publicUrl: ConfigSource
corsOrigins: ConfigSource
cliApiToken: 'env' | 'file' | 'generated'
}
class Configuration {
/** Telegram Bot API token */
public readonly telegramBotToken: string | null
/** Telegram bot enabled status (token present) */
public readonly telegramEnabled: boolean
/** Telegram notifications enabled */
public readonly telegramNotification: boolean
/** Server酱 SendKey/AppKey */
public readonly serverChanSendKey: string | null
/** Server酱 notifications enabled */
public readonly serverChanNotification: boolean
/** CLI auth token (shared secret) */
public cliApiToken: string
/** Source of CLI API token */
public cliApiTokenSource: 'env' | 'file' | 'generated' | ''
/** Whether CLI API token was newly generated (for first-run display) */
public cliApiTokenIsNew: boolean
/** Path to settings.json file */
public readonly settingsFile: string
/** Data directory for credentials and state */
public readonly dataDir: string
/** SQLite DB path */
public readonly dbPath: string
/** Port for the HTTP service */
public readonly listenPort: number
/** Host/IP to bind the HTTP service to */
public readonly listenHost: string
/** Public URL for external access (e.g., Telegram Mini App) */
public readonly publicUrl: string
/** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */
public readonly corsOrigins: string[]
/** Sources of each configuration value */
public readonly sources: ConfigSources
/** Private constructor - use createConfiguration() instead */
private constructor(
dataDir: string,
dbPath: string,
serverSettings: ServerSettings,
sources: ServerSettingsResult['sources']
) {
this.dataDir = dataDir
this.dbPath = dbPath
this.settingsFile = getSettingsFile(dataDir)
// Apply server settings
this.telegramBotToken = serverSettings.telegramBotToken
this.telegramEnabled = Boolean(this.telegramBotToken)
this.telegramNotification = serverSettings.telegramNotification
this.serverChanSendKey = serverSettings.serverChanSendKey
this.serverChanNotification = serverSettings.serverChanNotification
this.listenHost = serverSettings.listenHost
this.listenPort = serverSettings.listenPort
this.publicUrl = serverSettings.publicUrl
this.corsOrigins = serverSettings.corsOrigins
// CLI API token - will be set by _setCliApiToken() before create() returns
this.cliApiToken = ''
this.cliApiTokenSource = ''
this.cliApiTokenIsNew = false
// Store sources for logging (cliApiToken will be set by _setCliApiToken)
this.sources = {
...sources,
} as ConfigSources
// Ensure data directory exists
if (!existsSync(this.dataDir)) {
mkdirSync(this.dataDir, { recursive: true })
}
}
/** Create configuration asynchronously */
static async create(): Promise<Configuration> {
// 1. Determine data directory (env only - not persisted)
const dataDir = process.env.HAPI_HOME
? process.env.HAPI_HOME.replace(/^~/, homedir())
: join(homedir(), '.hapi')
// Ensure data directory exists before loading settings
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true })
}
// 2. Determine DB path (env only - not persisted)
const dbPath = process.env.DB_PATH
? process.env.DB_PATH.replace(/^~/, homedir())
: join(dataDir, 'hapi.db')
// 3. Load hub settings (with persistence)
const settingsResult = await loadServerSettings(dataDir)
if (settingsResult.savedToFile) {
console.log(`[Hub] Configuration saved to ${getSettingsFile(dataDir)}`)
}
// 4. Create configuration instance
const config = new Configuration(
dataDir,
dbPath,
settingsResult.settings,
settingsResult.sources
)
// 5. Load CLI API token
const tokenResult = await getOrCreateCliApiToken(dataDir)
config._setCliApiToken(tokenResult.token, tokenResult.source, tokenResult.isNew)
return config
}
/** Set CLI API token (called during async initialization) */
_setCliApiToken(token: string, source: 'env' | 'file' | 'generated', isNew: boolean): void {
this.cliApiToken = token
this.cliApiTokenSource = source
this.cliApiTokenIsNew = isNew
;(this.sources as { cliApiToken: string }).cliApiToken = source
}
}
// Singleton instance (set by createConfiguration)
let _configuration: Configuration | null = null
/**
* Create and initialize configuration asynchronously.
* Must be called once at startup before getConfiguration() can be used.
*/
export async function createConfiguration(): Promise<Configuration> {
if (_configuration) {
return _configuration
}
_configuration = await Configuration.create()
return _configuration
}
/**
* Get the initialized configuration.
* Throws if createConfiguration() has not been called yet.
*/
export function getConfiguration(): Configuration {
if (!_configuration) {
throw new Error('Configuration not initialized. Call createConfiguration() first.')
}
return _configuration
}