feat: add TLS certificate validation for tunnel access

Add tlsGate module to validate tunnel TLS certificates before announcing public access. This ensures the tunnel has a valid, trusted certificate before displaying the public URL to users. The validation checks certificate validity period, hostname matching (including wildcard support), and handles both DNS names and IP addresses.

Refactor tunnel access announcement to wait for TLS readiness asynchronously, preventing premature display of access links.
This commit is contained in:
weishu
2026-01-13 21:25:56 +08:00
parent e0c36b71e6
commit e8137cbeb2
2 changed files with 240 additions and 25 deletions
+37 -25
View File
@@ -23,6 +23,7 @@ import { PushService } from './push/pushService'
import { PushNotificationChannel } from './push/pushNotificationChannel'
import { VisibilityTracker } from './visibility/visibilityTracker'
import { TunnelManager } from './tunnel'
import { waitForTunnelTlsReady } from './tunnel/tlsGate'
import QRCode from 'qrcode'
import type { Server as BunServer } from 'bun'
import type { WebSocketData } from '@socket.io/bun-engine'
@@ -194,36 +195,47 @@ async function main() {
}
}
if (tunnelUrl) {
console.log('[Web] Public: ' + tunnelUrl)
if (tunnelUrl && tunnelManager) {
const manager = tunnelManager
const announceTunnelAccess = async () => {
const tlsReady = await waitForTunnelTlsReady(tunnelUrl, manager)
if (!tlsReady) {
console.log('[Tunnel] Tunnel stopped before TLS was ready.')
return
}
// Generate direct access link with server and token
const officialWebUrl = process.env.HAPI_OFFICIAL_WEB_URL || 'https://app.hapi.run'
const params = new URLSearchParams({
server: tunnelUrl,
token: config.cliApiToken
})
const directAccessUrl = `${officialWebUrl}/?${params.toString()}`
console.log('[Web] Public: ' + tunnelUrl)
console.log('')
console.log('Open in browser:')
console.log(` ${directAccessUrl}`)
console.log('')
console.log('or scan the QR code to open:')
// Display QR code for easy mobile access
try {
const qrString = await QRCode.toString(directAccessUrl, {
type: 'terminal',
small: true,
margin: 1,
errorCorrectionLevel: 'L'
// Generate direct access link with server and token
const officialWebUrl = process.env.HAPI_OFFICIAL_WEB_URL || 'https://app.hapi.run'
const params = new URLSearchParams({
server: tunnelUrl,
token: config.cliApiToken
})
const directAccessUrl = `${officialWebUrl}/?${params.toString()}`
console.log('')
console.log(qrString)
} catch {
// QR code generation failure should not affect main flow
console.log('Open in browser:')
console.log(` ${directAccessUrl}`)
console.log('')
console.log('or scan the QR code to open:')
// Display QR code for easy mobile access
try {
const qrString = await QRCode.toString(directAccessUrl, {
type: 'terminal',
small: true,
margin: 1,
errorCorrectionLevel: 'L'
})
console.log('')
console.log(qrString)
} catch {
// QR code generation failure should not affect main flow
}
}
void announceTunnelAccess()
}
console.log('')
console.log('HAPI Server is ready!')
+203
View File
@@ -0,0 +1,203 @@
import { isIP } from 'node:net'
import { connect, type PeerCertificate } from 'node:tls'
import type { TunnelManager } from './tunnelManager'
type SubjectAltName = {
type: 'DNS' | 'IP'
value: string
}
function parseSubjectAltNames(value: string | undefined): SubjectAltName[] {
if (!value) {
return []
}
return value
.split(',')
.map(entry => entry.trim())
.map(entry => {
const match = entry.match(/^(DNS|IP Address):\s*(.+)$/i)
if (!match) {
return null
}
const nameType = match[1].toLowerCase() === 'dns' ? 'DNS' : 'IP'
return { type: nameType, value: match[2].trim() }
})
.filter((entry): entry is SubjectAltName => Boolean(entry?.value))
}
function dnsNameMatchesHost(host: string, dnsName: string): boolean {
const normalizedHost = host.toLowerCase()
const normalizedDns = dnsName.toLowerCase()
if (normalizedDns === normalizedHost) {
return true
}
if (!normalizedDns.startsWith('*.')) {
return false
}
const suffix = normalizedDns.slice(2)
if (!normalizedHost.endsWith(`.${suffix}`)) {
return false
}
const remainder = normalizedHost.slice(0, normalizedHost.length - suffix.length - 1)
return remainder.length > 0 && !remainder.includes('.')
}
function hostMatchesCertificate(host: string, cert: PeerCertificate): boolean {
const altNames = parseSubjectAltNames(cert.subjectaltname)
const hostIsIp = isIP(host) !== 0
if (altNames.length > 0) {
if (hostIsIp) {
return altNames.some(name => name.type === 'IP' && name.value === host)
}
return altNames.some(name => name.type === 'DNS' && dnsNameMatchesHost(host, name.value))
}
const commonName = cert.subject?.CN
if (!commonName) {
return false
}
if (hostIsIp) {
return commonName === host
}
return dnsNameMatchesHost(host, commonName)
}
function parseCertDate(value: string | undefined): Date | null {
if (!value) {
return null
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function isCertificateTimeValid(cert: PeerCertificate): boolean {
const validFrom = parseCertDate(cert.valid_from)
const validTo = parseCertDate(cert.valid_to)
if (!validFrom || !validTo) {
return false
}
const now = Date.now()
const skewMs = 5 * 60 * 1000
if (validFrom.getTime() - skewMs > now) {
return false
}
if (validTo.getTime() + skewMs < now) {
return false
}
return true
}
function isValidTunnelCertificate(host: string, cert: PeerCertificate): boolean {
if (!isCertificateTimeValid(cert)) {
return false
}
if (!hostMatchesCertificate(host, cert)) {
return false
}
return true
}
async function checkTunnelCertificate(host: string, port: number, timeoutMs: number): Promise<boolean> {
return await new Promise(resolve => {
let resolved = false
const servername = isIP(host) === 0 ? host : undefined
const socket = connect({
host,
port,
servername,
rejectUnauthorized: false
})
const finalize = (result: boolean) => {
if (resolved) {
return
}
resolved = true
socket.destroy()
resolve(result)
}
const timer = setTimeout(() => finalize(false), timeoutMs)
socket.once('error', () => {
clearTimeout(timer)
finalize(false)
})
socket.once('secureConnect', () => {
clearTimeout(timer)
if (!socket.authorized) {
finalize(false)
return
}
const cert = socket.getPeerCertificate()
if (!cert || Object.keys(cert).length === 0) {
finalize(false)
return
}
finalize(isValidTunnelCertificate(host, cert))
})
})
}
export async function waitForTunnelTlsReady(tunnelUrl: string, tunnelManager: TunnelManager): Promise<boolean> {
let host: string | null = null
let port = 443
try {
const parsedUrl = new URL(tunnelUrl)
if (parsedUrl.protocol !== 'https:') {
return true
}
host = parsedUrl.hostname
if (parsedUrl.port) {
const parsedPort = Number.parseInt(parsedUrl.port, 10)
if (Number.isFinite(parsedPort)) {
port = parsedPort
}
}
} catch {
return true
}
if (!host) {
return true
}
const pollIntervalMs = 1500
const requestTimeoutMs = 2500
const logIntervalMs = 15000
let lastLogAt = 0
while (tunnelManager.isConnected()) {
if (await checkTunnelCertificate(host, port, requestTimeoutMs)) {
return true
}
const now = Date.now()
if (now - lastLogAt >= logIntervalMs) {
console.log('[Tunnel] Waiting for trusted TLS certificate...')
lastLogAt = now
}
await new Promise(resolve => setTimeout(resolve, pollIntervalMs))
}
return false
}