Ground-Zerro / Phobos Public
Code Issues Pull requests Actions Releases View on GitHub ↗
3.0 KB typescript
import { readdir, readFile } from 'node:fs/promises';

import { execFile } from './cmd';

const SNAPSHOT_TTL_MS = 500;

export const SOCKS5_STATS_DIR = '/run/wg-obfuscator';

export function socks5StatsPath(presetId: number): string {
  return `${SOCKS5_STATS_DIR}/socks5-${presetId}.stats`;
}

export type PeerRuntime = {
  endpoint: string | null;
  latestHandshakeAt: Date | null;
  transferRx: number;
  transferTx: number;
};

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

export type RuntimeSnapshot = {
  peers: ReadonlyMap<string, PeerRuntime>;
  socks5: ReadonlyMap<string, Socks5ClientStats>;
};

function parseWgDump(raw: string): Map<string, PeerRuntime> {
  const peers = new Map<string, PeerRuntime>();
  const lines = raw.split('\n');
  for (let i = 1; i < lines.length; i++) {
    const fields = lines[i]!.split('\t');
    if (fields.length < 7) continue;
    const handshake = fields[4]!;
    peers.set(fields[0]!, {
      endpoint: fields[2] === '(none)' ? null : fields[2]!,
      latestHandshakeAt:
        handshake === '0' ? null : new Date(Number(handshake) * 1000),
      transferRx: Number(fields[5]),
      transferTx: Number(fields[6]),
    });
  }
  return peers;
}

async function readPeers(interfaceName: string) {
  const raw = await execFile('wg', ['show', interfaceName, 'dump'], {
    log: false,
  });
  return parseWgDump(raw);
}

async function readSocks5Stats(): Promise<Map<string, Socks5ClientStats>> {
  const stats = new Map<string, Socks5ClientStats>();
  const files = await readdir(SOCKS5_STATS_DIR).catch(() => [] as string[]);
  const contents = await Promise.all(
    files
      .filter((file) => file.endsWith('.stats'))
      .map((file) =>
        readFile(`${SOCKS5_STATS_DIR}/${file}`, 'utf8').catch(() => '')
      )
  );
  for (const content of contents) {
    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;
}

class RuntimeStatsService {
  #snapshot: RuntimeSnapshot | null = null;
  #takenAt = 0;
  #pending: Promise<RuntimeSnapshot> | null = null;

  get(interfaceName: string): Promise<RuntimeSnapshot> {
    if (this.#snapshot && Date.now() - this.#takenAt < SNAPSHOT_TTL_MS) {
      return Promise.resolve(this.#snapshot);
    }
    this.#pending ??= this.#take(interfaceName).finally(() => {
      this.#pending = null;
    });
    return this.#pending;
  }

  async #take(interfaceName: string): Promise<RuntimeSnapshot> {
    const [peers, socks5] = await Promise.all([
      readPeers(interfaceName),
      readSocks5Stats(),
    ]);
    this.#snapshot = { peers, socks5 };
    this.#takenAt = Date.now();
    return this.#snapshot;
  }
}

export const RuntimeStats = new RuntimeStatsService();

export const runtimeStatsTestExports = { parseWgDump };