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:
weishu
2026-08-04 08:18:17 +08:00
parent cc8cc914bc
commit b67f4e56e5
8 changed files with 276 additions and 35 deletions
+2
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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)
+88
View File
@@ -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')
})
})
+96
View File
@@ -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)
}
+73 -25
View File
@@ -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
}