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
@@ -142,6 +142,8 @@ The terminal displays a URL and QR code. Scan to access from anywhere.
> **Tip:** The relay uses UDP by default. If you experience connectivity issues, set `HAPI_RELAY_FORCE_TCP=true` to force TCP mode.
The hub automatically stores an individually revocable relay key in `settings.json`. If that persisted key is revoked or the relay rotates its signing secret, HAPI discards it after HTTP 403, requests one replacement, and restarts the tunnel. Relay issuance is limited per public IP; HTTP 429 is reported explicitly, which can affect users sharing a CGNAT or corporate egress address. Set `HAPI_RELAY_AUTH` only when an operator has provided a key manually; rejected environment keys are never overwritten automatically.
### Local Only
```bash
+1 -1
View File
@@ -43,7 +43,7 @@ See `src/configuration.ts` for all options.
- `DB_PATH` - SQLite database path (default: HAPI_HOME/hapi.db).
- `TELEGRAM_NOTIFICATION` - Enable/disable Telegram notifications (default: true).
- `HAPI_RELAY_API` - Relay API domain (default: relay.hapi.run).
- `HAPI_RELAY_AUTH` - Relay auth key (default: hapi).
- `HAPI_RELAY_AUTH` - Explicit relay auth key. By default the hub obtains and persists an individually revocable key from the relay. A persisted key rejected with HTTP 403 is discarded and reissued once; an explicitly configured environment key must be updated manually.
- `HAPI_RELAY_FORCE_TCP` - Force TCP relay mode (true/1).
- `VAPID_SUBJECT` - Contact email/URL for Web Push.
+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
}