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