All files / kernel-cli/src/commands relay.ts

90.12% Statements 73/81
98.03% Branches 50/51
63.63% Functions 14/22
93.5% Lines 72/77

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221                          34x                 13x             7x                                                         6x 9x 9x 6x 1x   5x 13x 13x 5x 3x     5x 8x 8x   5x 1x   4x 5x 5x   4x                                 9x   9x 9x 1x     8x     8x 8x         1x 1x     7x 7x                 9x 1x   6x 6x 6x 1x     1x 1x 1x     6x                     6x 6x             3x 3x 1x   2x 1x   2x 2x                             7x   7x 2x 1x   2x 2x     5x 5x     5x 5x 3x       4x 1x 1x 1x       4x 3x 3x   1x   4x    
import { startRelay } from '@metamask/kernel-utils/libp2p';
import { getLibp2pRelayHome } from '@metamask/kernel-utils/nodejs';
import type { Logger } from '@metamask/logger';
import { mkdir, rm, writeFile } from 'node:fs/promises';
 
import { isProcessAlive, readPidFile, sendSignal, waitFor } from '../utils.ts';
 
/**
 * Get the relay PID file path.
 *
 * @returns The relay PID file path.
 */
export function getRelayPidPath(): string {
  return `${getLibp2pRelayHome()}/relay.pid`;
}
 
/**
 * Get the relay address file path.
 *
 * @returns The relay address file path.
 */
export function getRelayAddrPath(): string {
  return `${getLibp2pRelayHome()}/relay.addr`;
}
 
/**
 * Remove the relay PID and address files.
 */
async function removeRelayFiles(): Promise<void> {
  await Promise.all([
    rm(getRelayPidPath(), { force: true }),
    rm(getRelayAddrPath(), { force: true }),
  ]);
}
 
/**
 * Pick the multiaddr to write into `relay.addr`. We want the address
 * remote peers will actually be able to dial. Preference order:
 *
 *   1. A `/ip4/X.X.X.X/tcp/9001/ws/p2p/...` whose IPv4 part is neither
 *      loopback nor any RFC 1918 private range (i.e., looks public).
 *   2. Any non-loopback `/tcp/9001/ws/`.
 *   3. The loopback `/tcp/9001/ws/` as a last resort (single-host
 *      development).
 *
 * Without an `appendAnnounce` configured, libp2p's `getMultiaddrs()`
 * only reports addresses bound to local NICs, which on a NAT-backed
 * VPS is just loopback + a private interface — hence the
 * `LIBP2P_RELAY_PUBLIC_IP` / `--public-ip` knob feeds `appendAnnounce`
 * so a public hint is available here.
 *
 * @param multiaddrs - Multiaddrs reported by `libp2p.getMultiaddrs()`.
 * @returns The selected multiaddr, or `undefined` if no `/tcp/9001/ws/`
 * multiaddr is present at all.
 */
function pickRelayAddr(
  multiaddrs: readonly { toString(): string }[],
): string | undefined {
  const candidates = multiaddrs
    .map((ma) => ma.toString())
    .filter((addr) => addr.includes('/tcp/9001/ws/'));
  if (candidates.length === 0) {
    return undefined;
  }
  const ipOf = (addr: string): string | undefined =>
    /\/ip4\/([^/]+)\//u.exec(addr)?.[1];
  const isLoopback = (ip: string): boolean => ip === '127.0.0.1';
  const isPrivate = (ip: string): boolean =>
    ip.startsWith('10.') ||
    ip.startsWith('192.168.') ||
    /^172\.(1[6-9]|2\d|3[01])\./u.test(ip);
  const publicAddr = candidates.find((addr) => {
    const ip = ipOf(addr);
    return ip !== undefined && !isLoopback(ip) && !isPrivate(ip);
  });
  if (publicAddr) {
    return publicAddr;
  }
  const nonLoopback = candidates.find((addr) => {
    const ip = ipOf(addr);
    return ip !== undefined && !isLoopback(ip);
  });
  return nonLoopback ?? candidates[0];
}
 
/**
 * Start the relay server, write a PID file, and register signal handlers for
 * cleanup on exit.
 *
 * @param logger - The logger instance.
 * @param options - Optional configuration.
 * @param options.publicIp - Public IPv4 to announce alongside the
 * locally-bound addresses. Sourced by callers from
 * `LIBP2P_RELAY_PUBLIC_IP` or `--public-ip`.
 */
export async function startRelayWithBookkeeping(
  logger: Logger,
  options: { publicIp?: string } = {},
): Promise<void> {
  await mkdir(getLibp2pRelayHome(), { recursive: true });
 
  const existingPid = await readPidFile(getRelayPidPath());
  if (existingPid !== undefined && isProcessAlive(existingPid)) {
    throw new Error(`Relay is already running (PID: ${existingPid}).`);
  }
 
  await writeFile(getRelayPidPath(), String(process.pid));
 
  let libp2p;
  try {
    libp2p = await startRelay(
      logger,
      options.publicIp ? { publicIp: options.publicIp } : {},
    );
  } catch (error) {
    await removeRelayFiles();
    throw error;
  }
 
  try {
    const relayAddr = options.publicIp
      ? // Operator told us the externally-reachable IPv4 explicitly;
        // synthesize the multiaddr instead of trusting whatever libp2p's
        // address manager surfaces. Defends against scenarios where the
        // appendAnnounce entry never makes it into getMultiaddrs() — and
        // cleanly avoids the picker's heuristic for the common case
        // where the operator already knows the right answer.
        `/ip4/${options.publicIp}/tcp/9001/ws/p2p/${libp2p.peerId.toString()}`
      : pickRelayAddr(libp2p.getMultiaddrs());
    if (relayAddr === undefined) {
      throw new Error('Relay started but no /tcp/9001/ws multiaddr found');
    }
    await writeFile(getRelayAddrPath(), relayAddr);
    logger.info(`Relay address: ${relayAddr}`);
    if (options.publicIp) {
      logger.info(`(Used LIBP2P_RELAY_PUBLIC_IP=${options.publicIp}.)`);
    }
  } catch (error) {
    await Promise.resolve(libp2p.stop()).catch(() => undefined);
    await removeRelayFiles();
    throw error;
  }
 
  const cleanup = (): void => {
    Promise.resolve(libp2p.stop())
      .catch(() => undefined)
      .finally(() => {
        removeRelayFiles()
          .catch(() => undefined)
          // eslint-disable-next-line n/no-process-exit -- signal handler must force exit after cleanup
          .finally(() => process.exit(0));
      });
  };
 
  process.on('SIGTERM', cleanup);
  process.on('SIGINT', cleanup);
}
 
/**
 * Print whether the relay process is running.
 */
export async function printRelayStatus(): Promise<void> {
  const pid = await readPidFile(getRelayPidPath());
  if (pid !== undefined && isProcessAlive(pid)) {
    process.stderr.write(`Relay is running (PID: ${pid}).\n`);
  } else {
    if (pid !== undefined) {
      await removeRelayFiles();
    }
    process.stderr.write('Relay is not running.\n');
    process.exitCode = 1;
  }
}
 
/**
 * Stop the relay process. Sends SIGTERM and waits; escalates to SIGKILL if
 * `force` is true and SIGTERM is ignored.
 *
 * @param options - Options.
 * @param options.force - Send SIGKILL if SIGTERM fails to stop the relay.
 * @returns True if the relay was stopped (or was not running), false otherwise.
 */
export async function stopRelay({
  force = false,
}: { force?: boolean } = {}): Promise<boolean> {
  const pid = await readPidFile(getRelayPidPath());
 
  if (pid === undefined || !isProcessAlive(pid)) {
    if (pid !== undefined) {
      await removeRelayFiles();
    }
    process.stderr.write('Relay is not running.\n');
    return true;
  }
 
  process.stderr.write('Stopping relay...\n');
  let stopped = false;
 
  // Strategy 1: SIGTERM.
  stopped = !sendSignal(pid, 'SIGTERM');
  if (!stopped) {
    stopped = await waitFor(() => !isProcessAlive(pid), 5_000);
  }
 
  // Strategy 2: SIGKILL (only with --force).
  if (!stopped && force) {
    stopped = !sendSignal(pid, 'SIGKILL');
    Eif (!stopped) {
      stopped = await waitFor(() => !isProcessAlive(pid), 2_000);
    }
  }
 
  if (stopped) {
    await removeRelayFiles();
    process.stderr.write('Relay stopped.\n');
  } else {
    process.stderr.write('Relay did not stop within timeout.\n');
  }
  return stopped;
}