Ground-Zerro / Phobos Public
Code Issues Pull requests Actions Releases View on GitHub ↗
9.2 KB typescript
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createGzip } from 'node:zlib';
import debug from 'debug';
import tar from 'tar-stream';

import type { InterfaceType } from '#db/repositories/interface/types';
import { readTlsOrigin, isUntrustedTls } from '~~/server/utils/TlsInfo';

const PACKAGE_DEBUG = debug('PhobosPackage');

const PROD_BIN_DIR = '/app/phobos/bin';
const PROD_TEMPLATES_DIR = '/app/phobos/templates';

const ARCHITECTURES = ['x86_64', 'aarch64', 'armv7', 'mips', 'mipsel'] as const;

const TEMPLATES = [
  'lib-client.sh',
  'install-obfuscator.sh',
  'install-wireguard.sh',
  'router-configure-wireguard.sh',
  'router-configure-wireguard-openwrt.sh',
  'detect-router-arch.sh',
  'phobos-uninstall.sh',
] as const;

const SOCKS5_TEMPLATES = [
  'lib-client.sh',
  'install-obfuscator.sh',
  'install-wireguard.sh',
  'router-configure-socks5-keenetic.sh',
  'router-configure-socks5-openwrt.sh',
  'detect-router-arch.sh',
  'phobos-uninstall.sh',
] as const;

const TEMPLATE_WITH_PLACEHOLDER = 'install-router.sh.template';

function normalizeLf(content: string): string {
  return content.replaceAll('\r\n', '\n');
}

function resolveBinDir(): string {
  if (existsSync(PROD_BIN_DIR)) return PROD_BIN_DIR;
  return fileURLToPath(new URL('../../phobos-obfuscator/bin', import.meta.url));
}

function resolveTemplatesDir(): string {
  if (existsSync(PROD_TEMPLATES_DIR)) return PROD_TEMPLATES_DIR;
  return fileURLToPath(new URL('../phobos/templates', import.meta.url));
}

function clientSlug(name: string, id: ID): string {
  const base = name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 32);
  return base || `client-${id}`;
}

function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  return new Promise((ok, fail) => {
    const chunks: Buffer[] = [];
    stream.on('data', (c: Buffer) => chunks.push(c));
    stream.on('end', () => ok(Buffer.concat(chunks)));
    stream.on('error', fail);
  });
}

class PhobosPackageService {
  #cache = new Map<ID, Buffer>();

  async build(clientId: ID): Promise<Buffer> {
    const cached = this.#cache.get(clientId);
    if (cached) return cached;

    const iface = await Database.interfaces.get();
    const userConfig = await Database.userConfigs.get();
    const client = await Database.clients.get(clientId);
    if (!client) throw new Error(`Client ${clientId} not found`);

    const preset = await Database.obfuscatorPresets.getForClient(
      client.presetId ?? null
    );

    const slug = clientSlug(client.name, client.id);
    const pkgRoot = `phobos-${slug}`;

    const binDir = resolveBinDir();
    const pack = tar.pack();

    if (preset.mode === 'SOCKS5') {
      // A SOCKS5-mode client has no WireGuard role at all -- only the
      // client-side obfuscator conf, its router-install machinery (Keenetic
      // only for now), and the binaries needed to run it.
      const socks5Conf = Obfuscator.buildSocks5ClientObfConf(preset, iface);
      pack.entry(
        { name: `${pkgRoot}/wg-socks5-obfuscator.conf`, mode: 0o600 },
        socks5Conf
      );

      const { login: socks5Login, password: socks5Password } =
        await Database.clients.ensureSocks5Credentials(clientId);
      const credentials = [
        `SOCKS5_LOGIN='${socks5Login}'`,
        `SOCKS5_PASSWORD='${socks5Password}'`,
        '',
      ].join('\n');
      pack.entry(
        { name: `${pkgRoot}/socks5-credentials.env`, mode: 0o600 },
        credentials
      );

      const templatesDir = resolveTemplatesDir();

      const installRouter = (
        await readFile(resolve(templatesDir, TEMPLATE_WITH_PLACEHOLDER), 'utf8')
      )
        .replaceAll('{{CLIENT_NAME}}', slug)
        .replaceAll('{{PROTOCOL}}', 'socks5');
      pack.entry(
        { name: `${pkgRoot}/install-router.sh`, mode: 0o755 },
        normalizeLf(installRouter)
      );

      for (const file of SOCKS5_TEMPLATES) {
        const content = normalizeLf(
          await readFile(join(templatesDir, file), 'utf8')
        );
        pack.entry({ name: `${pkgRoot}/${file}`, mode: 0o755 }, content);
      }

      for (const arch of ARCHITECTURES) {
        const bin = join(binDir, `wg-obfuscator-${arch}`);
        if (existsSync(bin)) {
          const content = await readFile(bin);
          pack.entry(
            { name: `${pkgRoot}/bin/wg-obfuscator-${arch}`, mode: 0o755 },
            content
          );
        }
      }

      const readme = [
        'Phobos Client Package (SOCKS5)',
        `Client: ${client.name}`,
        `Slug: ${slug}`,
        `Built: ${new Date().toISOString()}`,
        '',
        'This client is assigned a SOCKS5-mode obfuscator preset, not WireGuard.',
        'On Keenetic routers, ./install-router.sh sets up the local obfuscator',
        'and a native SOCKS5 Proxy interface automatically. On other platforms,',
        'run the matching wg-obfuscator binary from bin/ with',
        'wg-socks5-obfuscator.conf (via -c) to get a local SOCKS5 proxy, then',
        "configure the login/password shown on this client's page in your",
        'SOCKS5 application.',
        '',
      ].join('\n');
      pack.entry({ name: `${pkgRoot}/README.txt` }, readme);

      pack.finalize();
      const gzipped = await streamToBuffer(pack.pipe(createGzip()));
      this.#cache.set(clientId, gzipped);
      PACKAGE_DEBUG(
        `built socks5 package for client ${clientId} (${gzipped.length} B)`
      );
      return gzipped;
    }

    const wgConf = wg.generateClientConfig(iface, userConfig, client, {
      enableIpv6: !WG_ENV.DISABLE_IPV6,
      clientWgLocalPort: preset.clientWgLocalPort,
    });

    const obfConf = Obfuscator.buildClientObfConf(preset, iface);

    const templatesDir = resolveTemplatesDir();

    pack.entry({ name: `${pkgRoot}/${slug}.conf`, mode: 0o600 }, wgConf);
    pack.entry({ name: `${pkgRoot}/wg-obfuscator.conf`, mode: 0o600 }, obfConf);

    const installRouter = (
      await readFile(resolve(templatesDir, TEMPLATE_WITH_PLACEHOLDER), 'utf8')
    )
      .replaceAll('{{CLIENT_NAME}}', slug)
      .replaceAll('{{PROTOCOL}}', 'wireguard');
    pack.entry(
      { name: `${pkgRoot}/install-router.sh`, mode: 0o755 },
      normalizeLf(installRouter)
    );

    for (const file of TEMPLATES) {
      const content = normalizeLf(
        await readFile(join(templatesDir, file), 'utf8')
      );
      pack.entry({ name: `${pkgRoot}/${file}`, mode: 0o755 }, content);
    }

    for (const arch of ARCHITECTURES) {
      const bin = join(binDir, `wg-obfuscator-${arch}`);
      if (existsSync(bin)) {
        const content = await readFile(bin);
        pack.entry(
          { name: `${pkgRoot}/bin/wg-obfuscator-${arch}`, mode: 0o755 },
          content
        );
      }
    }

    const readme = [
      'Phobos Client Package',
      `Client: ${client.name}`,
      `Slug: ${slug}`,
      `Built: ${new Date().toISOString()}`,
      '',
    ].join('\n');
    pack.entry({ name: `${pkgRoot}/README.txt` }, readme);

    pack.finalize();

    const gzipped = await streamToBuffer(pack.pipe(createGzip()));
    this.#cache.set(clientId, gzipped);
    PACKAGE_DEBUG(`built package for client ${clientId} (${gzipped.length} B)`);
    return gzipped;
  }

  invalidate(clientId?: ID): void {
    if (clientId === undefined) {
      this.#cache.clear();
      PACKAGE_DEBUG('cache fully invalidated');
    } else {
      this.#cache.delete(clientId);
      PACKAGE_DEBUG(`cache invalidated for client ${clientId}`);
    }
  }

  async installScript(token: string, origin: string): Promise<string> {
    const link = await Database.installLinks.getActiveByToken(token);
    if (!link) throw createError({ statusCode: 404 });

    const client = await Database.clients.get(link.id);
    if (!client) throw createError({ statusCode: 404 });

    const slug = clientSlug(client.name, client.id);
    const pkgUrl = `${origin}/api/install/${token}/package.tar.gz`;

    const untrusted =
      origin.startsWith('https://') && isUntrustedTls(readTlsOrigin());
    const curlFlags = untrusted ? '-fksSL' : '-fsSL';
    const wgetFlags = untrusted ? '-q --no-check-certificate' : '-q';

    const downloadSteps = [
      '#!/bin/sh',
      'set -e',
      `url="${pkgUrl}"`,
      'dir="/tmp/phobos_install_$$"',
      'mkdir -p "$dir"',
      'echo "Downloading Phobos package..."',
      'if command -v curl >/dev/null 2>&1; then',
      `  curl ${curlFlags} -o "$dir/package.tar.gz" "$url"`,
      'else',
      `  wget ${wgetFlags} -O "$dir/package.tar.gz" "$url"`,
      'fi',
      'if [ ! -s "$dir/package.tar.gz" ]; then',
      '  echo "Download failed"; exit 1',
      'fi',
      'cd "$dir"',
      'tar xzf package.tar.gz',
      `cd "phobos-${slug}"`,
    ];

    return [
      ...downloadSteps,
      'chmod +x install-router.sh',
      './install-router.sh',
      '',
    ].join('\n');
  }

  async getFilename(clientId: ID): Promise<string> {
    const client = await Database.clients.get(clientId);
    if (!client) throw new Error(`Client ${clientId} not found`);
    return `phobos-${clientSlug(client.name, client.id)}.tar.gz`;
  }
}

export const PhobosPackage = new PhobosPackageService();

export default PhobosPackage;