Ground-Zerro / Phobos Public
Code Issues Pull requests Actions Releases View on GitHub ↗
14.2 KB typescript
import { spawn, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs/promises';
import debug from 'debug';

import type { InterfaceType } from '#db/repositories/interface/types';
import type { ObfuscatorPresetType } from '#db/repositories/obfuscatorPreset/types';
import { WIREGUARD_PORT_MIN } from '#db/repositories/obfuscatorPreset/types';
import { WARP_FWMARK } from './WarpInterface';

const OBFUSCATOR_DEBUG = debug('Obfuscator');

const BINARY = '/usr/local/bin/wg-obfuscator';
const STATS_DIR = '/run/wg-obfuscator';
const PLACEHOLDER_KEY = 'changeme';
const BINARY_DIR = BINARY.slice(0, BINARY.lastIndexOf('/') + 1);
const BINARY_BASE = BINARY.slice(BINARY.lastIndexOf('/') + 1);
const PGREP_PATTERN = `${BINARY_DIR}[${BINARY_BASE[0]}]${BINARY_BASE.slice(1)}`;
const KEY_LENGTH_MIN = 200;
const KEY_LENGTH_MAX = 254;

function isPrivateIp(ip: string): boolean {
  return /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|169\.254\.)/.test(
    ip
  );
}

function randomKeyLength(): number {
  const span = KEY_LENGTH_MAX - KEY_LENGTH_MIN + 1;
  return KEY_LENGTH_MIN + (randomBytes(1)[0]! % span);
}

export function generateObfuscatorKey(
  length: number = randomKeyLength()
): string {
  if (length < 1 || length > 255) {
    throw new Error('Key length must be 1..255');
  }
  return randomBytes(length * 2)
    .toString('base64')
    .replace(/[+/=]/g, '')
    .slice(0, length);
}

const SOCKS5_CRED_CHARS =
  'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';

export function generateSocks5Credential(length = 5): string {
  const bytes = randomBytes(length);
  let out = '';
  for (let i = 0; i < length; i++) {
    out += SOCKS5_CRED_CHARS[bytes[i]! % SOCKS5_CRED_CHARS.length];
  }
  return out;
}

type RunningPreset = { child: ChildProcess; fingerprint: string };

export type Socks5ClientStats = {
  upBytes: number;
  downBytes: number;
  conns: number;
  idleMs: number;
};

class ObfuscatorService {
  #processes = new Map<number, RunningPreset>();
  #applyLock: Promise<void> = Promise.resolve();
  #reconcileTimer: ReturnType<typeof setTimeout> | null = null;
  #stopping = false;

  serverTarget(preset: ObfuscatorPresetType, wgPort: number): string {
    return preset.target?.trim() || `127.0.0.1:${wgPort}`;
  }

  #statsPath(presetId: number): string {
    return `${STATS_DIR}/socks5-${presetId}.stats`;
  }

  async readSocks5Stats(): Promise<Map<string, Socks5ClientStats>> {
    const stats = new Map<string, Socks5ClientStats>();
    const files = await fs.readdir(STATS_DIR).catch(() => [] as string[]);
    await Promise.all(
      files
        .filter((file) => file.endsWith('.stats'))
        .map(async (file) => {
          const content = await fs
            .readFile(`${STATS_DIR}/${file}`, 'utf8')
            .catch(() => '');
          for (const line of content.split('\n')) {
            const [login, up, down, conns, idle] = line.split(' ');
            if (!login || idle === undefined) continue;
            stats.set(login, {
              upBytes: Number(up),
              downBytes: Number(down),
              conns: Number(conns),
              idleMs: Number(idle),
            });
          }
        })
    );
    return stats;
  }

  buildArgs(
    preset: ObfuscatorPresetType,
    wgPort: number,
    socks5Users: string
  ): string[] {
    if (preset.mode === 'SOCKS5') {
      const args = [
        '--mode=socks5',
        '--role=server',
        `--source-if=${preset.sourceIf}`,
        `--source-lport=${preset.extPort}`,
        `--key=${preset.key}`,
        `--masking=${preset.masking}`,
        `--verbose=${preset.verbose}`,
        `--fwmark=${WARP_FWMARK}`,
        `--socks5-stats=${this.#statsPath(preset.id)}`,
      ];
      if (preset.masking === 'MEDIA' && preset.mediaSsrc != null) {
        args.push(`--media-ssrc=${preset.mediaSsrc}`);
      }
      if (socks5Users) {
        args.push(`--socks5-users=${socks5Users}`);
      }
      return args;
    }
    return [
      `--source-if=${preset.sourceIf}`,
      `--source-lport=${preset.extPort}`,
      `--target=${this.serverTarget(preset, wgPort)}`,
      `--key=${preset.key}`,
      `--masking=${preset.masking}`,
      `--obfuscate-bytes=${preset.obfuscateBytes}`,
      `--max-dummy=${preset.dummy}`,
      `--verbose=${preset.verbose}`,
    ];
  }

  fingerprint(
    preset: ObfuscatorPresetType,
    wgPort: number,
    socks5Users: string
  ): string {
    if (preset.mode === 'SOCKS5') {
      return [
        'SOCKS5',
        preset.sourceIf,
        preset.extPort,
        preset.key,
        preset.masking,
        preset.verbose,
        preset.mediaSsrc ?? '',
        socks5Users,
      ].join('|');
    }
    return [
      preset.extPort,
      preset.sourceIf,
      this.serverTarget(preset, wgPort),
      preset.key,
      preset.masking,
      preset.obfuscateBytes,
      preset.dummy,
      preset.verbose,
      wgPort,
    ].join('|');
  }

  spawnPreset(
    preset: ObfuscatorPresetType,
    wgPort: number,
    socks5Users: string
  ): void {
    const args = this.buildArgs(preset, wgPort, socks5Users);
    const child = spawn(BINARY, args, { stdio: 'inherit' });
    const fingerprint = this.fingerprint(preset, wgPort, socks5Users);
    this.#processes.set(preset.id, { child, fingerprint });

    OBFUSCATOR_DEBUG(
      `spawned preset ${preset.id} (${preset.name}) pid=${child.pid} port=${preset.extPort}`
    );

    child.on('exit', (code, signal) => {
      const current = this.#processes.get(preset.id);
      if (current?.child === child) {
        OBFUSCATOR_DEBUG(
          `preset ${preset.id} exited unexpectedly: code=${code} signal=${signal}; scheduling respawn`
        );
        this.#processes.delete(preset.id);
        this.#scheduleReconcile();
      }
    });

    child.on('error', (err) => {
      OBFUSCATOR_DEBUG(
        `preset ${preset.id} spawn error: ${(err as Error).message}`
      );
    });
  }

  async killPreset(presetId: number): Promise<void> {
    const entry = this.#processes.get(presetId);
    if (!entry) return;
    this.#processes.delete(presetId);
    await fs.rm(this.#statsPath(presetId), { force: true }).catch(() => {});

    const { child } = entry;
    if (child.exitCode !== null || child.signalCode !== null) {
      OBFUSCATOR_DEBUG(`preset ${presetId} pid=${child.pid} already exited`);
      return;
    }

    await new Promise<void>((resolve) => {
      let settled = false;
      const finish = () => {
        if (settled) return;
        settled = true;
        clearTimeout(sigkillTimer);
        clearTimeout(guardTimer);
        resolve();
      };

      child.once('exit', finish);

      const sigkillTimer = setTimeout(() => {
        try {
          child.kill('SIGKILL');
        } catch {
          // ignore
        }
      }, 2000);
      const guardTimer = setTimeout(finish, 5000);

      try {
        child.kill('SIGTERM');
      } catch {
        finish();
      }
    });

    OBFUSCATOR_DEBUG(`killed preset ${presetId} pid=${child.pid}`);
  }

  async #waitForNoBinary(timeoutMs = 5000): Promise<void> {
    const deadline = Date.now() + timeoutMs;
    let escalated = false;
    while (Date.now() < deadline) {
      const running = await exec(`pgrep -f '${PGREP_PATTERN}' || true`).catch(
        () => ''
      );
      if (!running.trim()) return;
      if (!escalated && Date.now() > deadline - timeoutMs / 2) {
        await exec(`pkill -9 -f '${PGREP_PATTERN}' || true`).catch(() => {});
        escalated = true;
      }
      await new Promise((r) => setTimeout(r, 150));
    }
  }

  #scheduleReconcile(delayMs = 2000): void {
    if (this.#stopping || this.#reconcileTimer) return;
    this.#reconcileTimer = setTimeout(() => {
      this.#reconcileTimer = null;
      this.applyAll().catch((err) =>
        OBFUSCATOR_DEBUG(`reconcile failed: ${(err as Error).message}`)
      );
    }, delayMs);
    this.#reconcileTimer.unref();
  }

  applyAll(): Promise<void> {
    const run = () => this.#applyAllInner();
    this.#applyLock = this.#applyLock.then(run, run);
    return this.#applyLock;
  }

  async #applyAllInner(): Promise<void> {
    const presets = await Database.obfuscatorPresets.list();
    const iface = await Database.interfaces.get();
    const socks5Members = await Database.clients.getSocks5PresetMembers();

    const usersByPreset = new Map<number, string>();
    for (const preset of presets) {
      if (preset.mode !== 'SOCKS5') continue;
      const members = socks5Members
        .filter((m) => m.presetId === preset.id)
        .map((m) => `${m.socks5Login}:${m.socks5Password}`)
        .sort();
      usersByPreset.set(preset.id, members.join(','));
    }

    const desired = new Map(presets.map((p) => [p.id, p]));

    for (const id of [...this.#processes.keys()]) {
      if (!desired.has(id)) await this.killPreset(id);
    }

    for (const preset of presets) {
      const socks5Users = usersByPreset.get(preset.id) ?? '';
      const current = this.#processes.get(preset.id);
      const want = this.fingerprint(preset, iface.port, socks5Users);
      if (current && current.fingerprint === want) continue;
      if (current) await this.killPreset(preset.id);
      this.spawnPreset(preset, iface.port, socks5Users);
    }

    OBFUSCATOR_DEBUG(
      `applied ${presets.length} presets, ${this.#processes.size} processes running`
    );
  }

  async Shutdown(): Promise<void> {
    OBFUSCATOR_DEBUG('Shutting down obfuscator presets');
    this.#stopping = true;
    if (this.#reconcileTimer) {
      clearTimeout(this.#reconcileTimer);
      this.#reconcileTimer = null;
    }
    for (const id of [...this.#processes.keys()]) await this.killPreset(id);
    await exec(`pkill -f '${PGREP_PATTERN}' || true`).catch(() => {});
  }

  async detectPublicIpV4(): Promise<string> {
    const envIp = process.env.WG_HOST;
    if (envIp && /^\d+\.\d+\.\d+\.\d+$/.test(envIp)) return envIp;

    const route = await exec('ip route');
    const iface = route.match(/^default.+dev\s+(\S+)/m)?.[1];
    if (iface) {
      const out = await exec(`ip -4 addr show dev ${iface} scope global`);
      const ip = out.match(/inet\s+(\d+\.\d+\.\d+\.\d+)/)?.[1];
      if (ip && !isPrivateIp(ip)) return ip;
    }

    const pub = await exec('curl -sf --max-time 5 https://api.ipify.org').catch(
      () => ''
    );
    if (pub && /^\d+\.\d+\.\d+\.\d+$/.test(pub.trim())) return pub.trim();

    throw new Error('Cannot detect public IPv4. Set WG_HOST env variable.');
  }

  async detectPublicIpV6(): Promise<string | null> {
    try {
      const route = await exec('ip route');
      const iface = route.match(/^default.+dev\s+(\S+)/m)?.[1];
      if (!iface) return null;

      const out = await exec(`ip -6 addr show dev ${iface} scope global`);
      const ipv6 = out
        .match(/inet6\s+([0-9a-f:]+)/gi)
        ?.map((l) => l.replace(/^inet6\s+/i, ''))
        .find((addr) => !/^f[cd]/i.test(addr));
      return ipv6 ?? null;
    } catch {
      return null;
    }
  }

  buildClientObfConf(
    preset: ObfuscatorPresetType,
    iface: InterfaceType
  ): string {
    return [
      '[instance]',
      'source-if = 127.0.0.1',
      `source-lport = ${preset.clientWgLocalPort}`,
      `target = ${iface.serverPublicDomain || iface.serverPublicIpV4}:${preset.extPort}`,
      `key = ${preset.key}`,
      `masking = ${preset.masking}`,
      `obfuscate-bytes = ${preset.obfuscateBytes}`,
      `max-dummy = ${preset.dummy}`,
      `verbose = ${preset.verbose}`,
      '',
    ].join('\n');
  }

  buildSocks5ClientObfConf(
    preset: ObfuscatorPresetType,
    iface: InterfaceType
  ): string {
    const lines = [
      '[instance]',
      'mode = socks5',
      'role = client',
      'source-if = 127.0.0.1',
      `source-lport = ${preset.clientLocalPort}`,
      `target = ${iface.serverPublicDomain || iface.serverPublicIpV4}:${preset.extPort}`,
      `key = ${preset.key}`,
      `masking = ${preset.masking}`,
      `verbose = ${preset.verbose}`,
    ];
    if (preset.masking === 'MEDIA' && preset.mediaSsrc != null) {
      lines.push(`media-ssrc = ${preset.mediaSsrc}`);
    }
    lines.push('');
    return lines.join('\n');
  }

  buildSocks5CredentialsSection(login: string, password: string): string {
    return ['[socks5]', `login = ${login}`, `password = ${password}`, ''].join(
      '\n'
    );
  }

  async #rotatePlaceholderKeys(): Promise<void> {
    const presets = await Database.obfuscatorPresets.list();
    const placeholders = presets.filter((p) => p.key === PLACEHOLDER_KEY);
    if (placeholders.length === 0) return;

    const clients = await Database.clients.getAll();
    if (clients.length > 0) {
      OBFUSCATOR_DEBUG(
        `placeholder key present but ${clients.length} client(s) exist; leaving key unchanged to avoid breaking deployed configs`
      );
      return;
    }

    for (const preset of placeholders) {
      await Database.obfuscatorPresets.regenerateKey(preset.id);
      OBFUSCATOR_DEBUG(
        `rotated placeholder obfuscation key for preset ${preset.id} (${preset.name})`
      );
    }
  }

  async Startup(): Promise<void> {
    OBFUSCATOR_DEBUG('Starting Obfuscator...');
    this.#stopping = false;

    await fs.mkdir(STATS_DIR, { recursive: true });
    for (const file of await fs
      .readdir(STATS_DIR)
      .catch(() => [] as string[])) {
      await fs.rm(`${STATS_DIR}/${file}`, { force: true }).catch(() => {});
    }

    const iface = await Database.interfaces.get();

    if (!iface.serverPublicIpV4) {
      const ipv4 = await this.detectPublicIpV4().catch(() => '');
      const ipv6 = iface.serverPublicIpV6 ?? (await this.detectPublicIpV6());
      if (ipv4 || ipv6 !== iface.serverPublicIpV6) {
        await Database.interfaces.update({
          serverPublicIpV4: ipv4,
          serverPublicIpV6: ipv6,
        });
      }
    }

    await Database.obfuscatorPresets.ensureDefault({
      extPort: WIREGUARD_PORT_MIN,
      sourceIf: '0.0.0.0',
      target: null,
      key: generateObfuscatorKey(),
      masking: 'MEDIA',
      obfuscateBytes: 0,
      dummy: 40,
      verbose: 'error',
      clientWgLocalPort: 13255,
    });

    await this.#rotatePlaceholderKeys();

    await exec(`pkill -f '${PGREP_PATTERN}' || true`).catch(() => {});
    await this.#waitForNoBinary();

    await this.applyAll();

    OBFUSCATOR_DEBUG('Obfuscator started');
  }
}

export const Obfuscator = new ObfuscatorService();

export default Obfuscator;