mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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.
This commit is contained in:
@@ -20,6 +20,8 @@ export interface Settings {
|
||||
listenPort?: number
|
||||
publicUrl?: string
|
||||
corsOrigins?: string[]
|
||||
/** Per-hub relay auth key issued by the relay server (/issue) */
|
||||
relayAuthKey?: string
|
||||
}
|
||||
|
||||
export function getSettingsFile(dataDir: string): string {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* - 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 for tunwg (default: hapi)
|
||||
* - 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)
|
||||
|
||||
+13
-8
@@ -16,6 +16,7 @@ import { FcmNotificationChannel } from './fcm/fcmNotificationChannel'
|
||||
import { resolveFcmConfig } from './fcm/fcmConfig'
|
||||
import { VisibilityTracker } from './visibility/visibilityTracker'
|
||||
import { TunnelManager } from './tunnel'
|
||||
import { refreshRejectedRelayAuthKey, resolveRelayAuthKey } from './tunnel/relayAuth'
|
||||
import { waitForTunnelTlsReady } from './tunnel/tlsGate'
|
||||
import { ServerChanChannel } from './serverchan/channel'
|
||||
import QRCode from 'qrcode'
|
||||
@@ -273,15 +274,19 @@ export async function startHub(options: StartHubOptions = {}): Promise<HubInstan
|
||||
// Initialize tunnel AFTER web service is ready
|
||||
let tunnelUrl: string | null = null
|
||||
if (relayFlag.enabled) {
|
||||
tunnelManager = new TunnelManager({
|
||||
localPort: config.listenPort,
|
||||
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 {
|
||||
tunnelManager = new TunnelManager({
|
||||
localPort: config.listenPort,
|
||||
enabled: true,
|
||||
apiDomain: relayApiDomain,
|
||||
authKey: await resolveRelayAuthKey(relayApiDomain, config.settingsFile),
|
||||
refreshAuthKey: rejectedKey => refreshRejectedRelayAuthKey(
|
||||
relayApiDomain,
|
||||
config.settingsFile,
|
||||
rejectedKey
|
||||
),
|
||||
useRelay: process.env.HAPI_RELAY_FORCE_TCP === 'true' || process.env.HAPI_RELAY_FORCE_TCP === '1'
|
||||
})
|
||||
tunnelUrl = await tunnelManager.start()
|
||||
} catch (error) {
|
||||
console.error('[Tunnel] Failed to start:', error instanceof Error ? error.message : error)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { refreshRejectedRelayAuthKey, resolveRelayAuthKey } from './relayAuth'
|
||||
|
||||
function makeSettingsFile(): { dir: string; file: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hapi-relay-auth-test-'))
|
||||
return { dir, file: join(dir, 'settings.json') }
|
||||
}
|
||||
|
||||
describe('relay auth recovery', () => {
|
||||
const originalEnvKey = process.env.HAPI_RELAY_AUTH
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvKey === undefined) {
|
||||
delete process.env.HAPI_RELAY_AUTH
|
||||
} else {
|
||||
process.env.HAPI_RELAY_AUTH = originalEnvKey
|
||||
}
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces a rejected persisted key', async () => {
|
||||
delete process.env.HAPI_RELAY_AUTH
|
||||
const { dir, file } = makeSettingsFile()
|
||||
tempDirs.push(dir)
|
||||
writeFileSync(file, JSON.stringify({ relayAuthKey: 'rejected-key', listenPort: 3000 }))
|
||||
|
||||
const key = await refreshRejectedRelayAuthKey(
|
||||
'relay.example.com',
|
||||
file,
|
||||
'rejected-key',
|
||||
async () => Response.json({ key: 'replacement-key' })
|
||||
)
|
||||
|
||||
expect(key).toBe('replacement-key')
|
||||
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
|
||||
relayAuthKey: 'replacement-key',
|
||||
listenPort: 3000
|
||||
})
|
||||
})
|
||||
|
||||
it('does not overwrite an explicitly configured rejected key', async () => {
|
||||
process.env.HAPI_RELAY_AUTH = 'env-key'
|
||||
const { dir, file } = makeSettingsFile()
|
||||
tempDirs.push(dir)
|
||||
|
||||
await expect(refreshRejectedRelayAuthKey(
|
||||
'relay.example.com',
|
||||
file,
|
||||
'env-key',
|
||||
async () => Response.json({ key: 'replacement-key' })
|
||||
)).rejects.toThrow('Update or unset the environment variable')
|
||||
})
|
||||
|
||||
it('explains shared-IP issuance limits and discards the rejected key', async () => {
|
||||
delete process.env.HAPI_RELAY_AUTH
|
||||
const { dir, file } = makeSettingsFile()
|
||||
tempDirs.push(dir)
|
||||
writeFileSync(file, JSON.stringify({ relayAuthKey: 'rejected-key' }))
|
||||
|
||||
const error = refreshRejectedRelayAuthKey(
|
||||
'relay.example.com',
|
||||
file,
|
||||
'rejected-key',
|
||||
async () => new Response(null, { status: 429, headers: { 'Retry-After': '3600' } })
|
||||
)
|
||||
await expect(error).rejects.toThrow('limits issuance per public IP')
|
||||
await expect(error).rejects.toThrow('Retry after 3600 seconds')
|
||||
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({})
|
||||
})
|
||||
|
||||
it('reports a clear 429 error during initial resolution', async () => {
|
||||
delete process.env.HAPI_RELAY_AUTH
|
||||
const { dir, file } = makeSettingsFile()
|
||||
tempDirs.push(dir)
|
||||
|
||||
await expect(resolveRelayAuthKey(
|
||||
'relay.example.com',
|
||||
file,
|
||||
async () => new Response(null, { status: 429 })
|
||||
)).rejects.toThrow('Configure HAPI_RELAY_AUTH')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Relay auth key resolution
|
||||
*
|
||||
* Priority: HAPI_RELAY_AUTH env > persisted per-hub key > freshly issued key.
|
||||
* The relay server only accepts per-hub HMAC keys issued via /issue; once
|
||||
* obtained the key is persisted to settings.json so every hub has a stable,
|
||||
* individually revocable identity. A persisted key rejected by /add is
|
||||
* discarded and replaced once. Failure to obtain a key is fatal for the
|
||||
* tunnel — there is no shared-key fallback.
|
||||
*/
|
||||
|
||||
import { readSettings, writeSettings, type Settings } from '../config/settings'
|
||||
|
||||
type FetchRelayAuth = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
|
||||
|
||||
async function issueRelayAuthKey(
|
||||
apiDomain: string,
|
||||
settingsFile: string,
|
||||
settings: Settings | null,
|
||||
fetchRelayAuth: FetchRelayAuth
|
||||
): Promise<string> {
|
||||
const resp = await fetchRelayAuth(`https://${apiDomain}/issue`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
})
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 429) {
|
||||
const retryAfter = resp.headers.get('Retry-After')
|
||||
const retryHint = retryAfter
|
||||
? ` Retry after ${retryAfter} seconds.`
|
||||
: ' Retry later.'
|
||||
throw new Error(
|
||||
'Relay key issuance rate-limited (HTTP 429). ' +
|
||||
'The relay limits issuance per public IP.' + retryHint +
|
||||
' Configure HAPI_RELAY_AUTH if an operator provided a key.'
|
||||
)
|
||||
}
|
||||
throw new Error(`Relay at ${apiDomain} refused to issue an auth key (HTTP ${resp.status}).`)
|
||||
}
|
||||
const data = await resp.json() as { key?: string }
|
||||
if (typeof data.key !== 'string' || !data.key) {
|
||||
throw new Error(`Relay at ${apiDomain} returned an invalid key response.`)
|
||||
}
|
||||
// settings === null means the file exists but is unparseable; don't clobber it
|
||||
if (settings !== null) {
|
||||
await writeSettings(settingsFile, { ...settings, relayAuthKey: data.key })
|
||||
}
|
||||
console.log('[Tunnel] Obtained per-hub relay auth key')
|
||||
return data.key
|
||||
}
|
||||
|
||||
export async function resolveRelayAuthKey(
|
||||
apiDomain: string,
|
||||
settingsFile: string,
|
||||
fetchRelayAuth: FetchRelayAuth = fetch
|
||||
): Promise<string> {
|
||||
const envKey = process.env.HAPI_RELAY_AUTH
|
||||
if (envKey) {
|
||||
return envKey
|
||||
}
|
||||
|
||||
const settings = await readSettings(settingsFile)
|
||||
if (settings?.relayAuthKey) {
|
||||
return settings.relayAuthKey
|
||||
}
|
||||
|
||||
return issueRelayAuthKey(apiDomain, settingsFile, settings, fetchRelayAuth)
|
||||
}
|
||||
|
||||
export async function refreshRejectedRelayAuthKey(
|
||||
apiDomain: string,
|
||||
settingsFile: string,
|
||||
rejectedKey: string,
|
||||
fetchRelayAuth: FetchRelayAuth = fetch
|
||||
): Promise<string> {
|
||||
if (process.env.HAPI_RELAY_AUTH) {
|
||||
throw new Error(
|
||||
'HAPI_RELAY_AUTH was rejected by the relay (HTTP 403). ' +
|
||||
'Update or unset the environment variable; persisted settings cannot override it.'
|
||||
)
|
||||
}
|
||||
|
||||
const settings = await readSettings(settingsFile)
|
||||
if (settings === null) {
|
||||
throw new Error(`Cannot refresh relay auth while ${settingsFile} is unreadable.`)
|
||||
}
|
||||
if (settings.relayAuthKey && settings.relayAuthKey !== rejectedKey) {
|
||||
return settings.relayAuthKey
|
||||
}
|
||||
|
||||
const clearedSettings = { ...settings }
|
||||
delete clearedSettings.relayAuthKey
|
||||
await writeSettings(settingsFile, clearedSettings)
|
||||
console.warn('[Tunnel] Relay auth key rejected; requesting a replacement')
|
||||
return issueRelayAuthKey(apiDomain, settingsFile, clearedSettings, fetchRelayAuth)
|
||||
}
|
||||
@@ -58,8 +58,9 @@ export interface TunnelConfig {
|
||||
localPort: number
|
||||
enabled: boolean
|
||||
apiDomain?: string | null // TUNWG_API - default: relay.hapi.run (official relay)
|
||||
authKey?: string | null // TUNWG_AUTH - default: hapi
|
||||
authKey: string // TUNWG_AUTH - per-hub key issued by the relay
|
||||
useRelay?: boolean // TUNWG_RELAY
|
||||
refreshAuthKey?: (rejectedKey: string) => Promise<string>
|
||||
}
|
||||
|
||||
interface TunnelState {
|
||||
@@ -77,6 +78,7 @@ export class TunnelManager {
|
||||
private readonly retryDelayMs = 3000
|
||||
private retryTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private stopped = false
|
||||
private authRecoveryAttempted = false
|
||||
|
||||
constructor(config: TunnelConfig) {
|
||||
this.config = config
|
||||
@@ -116,7 +118,7 @@ export class TunnelManager {
|
||||
if (this.config.apiDomain) {
|
||||
env.TUNWG_API = this.config.apiDomain
|
||||
}
|
||||
env.TUNWG_AUTH = this.config.authKey ?? 'hapi'
|
||||
env.TUNWG_AUTH = this.config.authKey
|
||||
if (this.config.useRelay) {
|
||||
env.TUNWG_RELAY = 'true'
|
||||
}
|
||||
@@ -124,8 +126,11 @@ export class TunnelManager {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`[Tunnel] Starting tunnel to ${forwardUrl}...`)
|
||||
|
||||
// --json switches tunwg's slog output (stderr) to JSON; the tunnel URL
|
||||
// arrives as a {"msg":"listener started","url":...} log record.
|
||||
// --log_level=0 (info) suppresses per-request debug/access logs.
|
||||
const proc = spawn({
|
||||
cmd: [tunwgPath, '--json', `--forward=${forwardUrl}`],
|
||||
cmd: [tunwgPath, '--json', '--log_level=0', `--forward=${forwardUrl}`],
|
||||
env,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
@@ -137,6 +142,39 @@ export class TunnelManager {
|
||||
let stdoutBuffer = ''
|
||||
|
||||
let resolved = false
|
||||
let authRecoveryStarted = false
|
||||
|
||||
const recoverRejectedAuth = async (): Promise<void> => {
|
||||
if (authRecoveryStarted || this.authRecoveryAttempted || !this.config.refreshAuthKey) {
|
||||
return
|
||||
}
|
||||
authRecoveryStarted = true
|
||||
this.authRecoveryAttempted = true
|
||||
try {
|
||||
const replacement = await this.config.refreshAuthKey(this.config.authKey)
|
||||
this.config.authKey = replacement
|
||||
proc.kill()
|
||||
await proc.exited
|
||||
if (this.stopped) {
|
||||
throw new Error('Tunnel stopped')
|
||||
}
|
||||
const url = await this.spawnTunwg()
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
resolve(url)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
this.state.lastError = message
|
||||
proc.kill()
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
reject(error)
|
||||
} else {
|
||||
console.error('[Tunnel] Relay auth recovery failed:', message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readStdout = async (): Promise<void> => {
|
||||
const reader = proc.stdout.getReader()
|
||||
@@ -153,23 +191,9 @@ export class TunnelManager {
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
continue
|
||||
if (trimmed) {
|
||||
console.log(`[Tunnel] ${trimmed}`)
|
||||
}
|
||||
|
||||
const parsed = this.parseTunwgEvent(trimmed)
|
||||
if (parsed && parsed.event === 'ready' && typeof parsed.url === 'string') {
|
||||
if (!resolved) {
|
||||
this.state.tunnelUrl = parsed.url
|
||||
this.state.isConnected = true
|
||||
this.state.retryCount = 0
|
||||
resolved = true
|
||||
resolve(parsed.url)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`[Tunnel] ${trimmed}`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -179,7 +203,8 @@ export class TunnelManager {
|
||||
|
||||
readStdout()
|
||||
|
||||
// Handle stderr (logs and warnings)
|
||||
// slog JSON records arrive on stderr; the tunnel URL comes from
|
||||
// the "listener started" record.
|
||||
const readStderr = async (): Promise<void> => {
|
||||
const reader = proc.stderr.getReader()
|
||||
let stderrBuffer = ''
|
||||
@@ -195,9 +220,28 @@ export class TunnelManager {
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) {
|
||||
console.log(`[Tunnel] ${trimmed}`)
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = this.parseTunwgLog(trimmed)
|
||||
if (parsed && parsed.msg === 'listener started' && typeof parsed.url === 'string') {
|
||||
if (!resolved) {
|
||||
this.state.tunnelUrl = parsed.url
|
||||
this.state.isConnected = true
|
||||
this.state.retryCount = 0
|
||||
this.authRecoveryAttempted = false
|
||||
resolved = true
|
||||
resolve(parsed.url)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsed?.event === 'peer_registration_rejected' && parsed.status === 403) {
|
||||
void recoverRejectedAuth()
|
||||
}
|
||||
|
||||
console.log(`[Tunnel] ${trimmed}`)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -212,6 +256,10 @@ export class TunnelManager {
|
||||
this.state.isConnected = false
|
||||
this.state.process = null
|
||||
|
||||
if (authRecoveryStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.stopped) {
|
||||
// Stopped intentionally - reject if still pending
|
||||
if (!resolved) {
|
||||
@@ -254,7 +302,7 @@ export class TunnelManager {
|
||||
|
||||
// Timeout for initial URL capture
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
if (!resolved && !authRecoveryStarted) {
|
||||
resolved = true
|
||||
reject(new Error('Timeout waiting for tunnel URL'))
|
||||
}
|
||||
@@ -262,9 +310,9 @@ export class TunnelManager {
|
||||
})
|
||||
}
|
||||
|
||||
private parseTunwgEvent(line: string): { event?: string; url?: string } | null {
|
||||
private parseTunwgLog(line: string): { msg?: string; url?: string; event?: string; status?: number } | null {
|
||||
try {
|
||||
return JSON.parse(line) as { event?: string; url?: string }
|
||||
return JSON.parse(line) as { msg?: string; url?: string; event?: string; status?: number }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user