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:
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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