mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
+37
-25
@@ -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!')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user