network-identity.service.ts raw
1 /**
2 * Detects the user's external IP address for per-network relay stats keying.
3 * Queries multiple public IP services in parallel, uses first response.
4 * Caches result for session lifetime.
5 */
6
7 const IP_SERVICES = [
8 {
9 url: 'https://api.ipify.org?format=json',
10 parse: (text: string) => {
11 try { return JSON.parse(text).ip as string } catch { return null }
12 }
13 },
14 {
15 url: 'https://ifconfig.me/ip',
16 parse: (text: string) => text.trim()
17 },
18 {
19 url: 'https://icanhazip.com',
20 parse: (text: string) => text.trim()
21 }
22 ]
23
24 /** Convert dotted-quad IPv4 string to 8-char hex string (e.g. "c0a80001") */
25 export function ipToHex(ip: string): string {
26 const parts = ip.split('.')
27 if (parts.length !== 4) return '00000000'
28 return parts
29 .map((p) => {
30 const n = parseInt(p, 10)
31 return (isNaN(n) || n < 0 || n > 255) ? '00' : n.toString(16).padStart(2, '0')
32 })
33 .join('')
34 }
35
36 /** Convert 8-char hex string to dotted-quad IPv4 */
37 export function hexToIp(hex: string): string {
38 if (hex.length !== 8) return '0.0.0.0'
39 const parts: number[] = []
40 for (let i = 0; i < 8; i += 2) {
41 parts.push(parseInt(hex.slice(i, i + 2), 16))
42 }
43 return parts.join('.')
44 }
45
46 /** Convert dotted-quad to 4-byte Uint8Array */
47 export function ipToBytes(ip: string): Uint8Array {
48 const parts = ip.split('.')
49 const bytes = new Uint8Array(4)
50 for (let i = 0; i < 4; i++) {
51 const n = parseInt(parts[i], 10)
52 bytes[i] = isNaN(n) ? 0 : n & 0xff
53 }
54 return bytes
55 }
56
57 /** Convert 4-byte Uint8Array to dotted-quad */
58 export function bytesToIp(bytes: Uint8Array): string {
59 return `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`
60 }
61
62 const IPV4_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/
63
64 class NetworkIdentityService {
65 private cachedIp: string | null = null
66 private detectPromise: Promise<string | null> | null = null
67
68 /** Get current external IP hex key. Returns null if detection failed. */
69 getCurrentIpHex(): string | null {
70 return this.cachedIp ? ipToHex(this.cachedIp) : null
71 }
72
73 /** Get current external IP as dotted-quad. Returns null if detection failed. */
74 getCurrentIp(): string | null {
75 return this.cachedIp
76 }
77
78 /** Detect external IP. Call once at session start. Returns cached result on subsequent calls. */
79 async detect(): Promise<string | null> {
80 if (this.cachedIp) return this.cachedIp
81 if (this.detectPromise) return this.detectPromise
82
83 this.detectPromise = this.raceDetect()
84 this.cachedIp = await this.detectPromise
85 this.detectPromise = null
86 return this.cachedIp
87 }
88
89 private async raceDetect(): Promise<string | null> {
90 const controller = new AbortController()
91 const timeout = setTimeout(() => controller.abort(), 5000)
92
93 try {
94 const result = await Promise.any(
95 IP_SERVICES.map(async (service) => {
96 const resp = await fetch(service.url, { signal: controller.signal })
97 if (!resp.ok) throw new Error(`${service.url}: ${resp.status}`)
98 const text = await resp.text()
99 const ip = service.parse(text)
100 if (!ip || !IPV4_REGEX.test(ip)) throw new Error(`${service.url}: invalid IP "${ip}"`)
101 return ip
102 })
103 )
104 return result
105 } catch {
106 return null
107 } finally {
108 clearTimeout(timeout)
109 }
110 }
111 }
112
113 const networkIdentityService = new NetworkIdentityService()
114 export default networkIdentityService
115