Tighten types: remove anys, validate funnel status, typed exec helpers

This commit is contained in:
Peter Steinberger
2025-11-24 11:38:04 +01:00
parent a2b73ec571
commit 1526c238bd

View File

@@ -4,7 +4,7 @@ import dotenv from 'dotenv';
import process from 'node:process'; import process from 'node:process';
import Twilio from 'twilio'; import Twilio from 'twilio';
import type { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message.js'; import type { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message.js';
import express from 'express'; import express, { type Request, type Response } from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
@@ -62,13 +62,21 @@ function readEnv(): EnvConfig {
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
async function ensureBinary(name: string) { type ExecResult = { stdout: string; stderr: string };
try {
await execFileAsync('which', [name]); async function runExec(command: string, args: string[], maxBuffer = 2_000_000): Promise<ExecResult> {
} catch { const { stdout, stderr } = await execFileAsync(command, args, {
maxBuffer,
encoding: 'utf8'
});
return { stdout, stderr };
}
async function ensureBinary(name: string): Promise<void> {
await runExec('which', [name]).catch(() => {
console.error(`Missing required binary: ${name}. Please install it.`); console.error(`Missing required binary: ${name}. Please install it.`);
process.exit(1); process.exit(1);
} });
} }
function withWhatsAppPrefix(number: string): string { function withWhatsAppPrefix(number: string): string {
@@ -94,7 +102,9 @@ function loadConfig(): WarelayConfig {
try { try {
if (!fs.existsSync(CONFIG_PATH)) return {}; if (!fs.existsSync(CONFIG_PATH)) return {};
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8'); const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
return JSON5.parse(raw) as WarelayConfig; const parsed = JSON5.parse(raw);
if (typeof parsed !== 'object' || parsed === null) return {};
return parsed as WarelayConfig;
} catch (err) { } catch (err) {
console.error(`Failed to read config at ${CONFIG_PATH}`, err); console.error(`Failed to read config at ${CONFIG_PATH}`, err);
return {}; return {};
@@ -228,7 +238,7 @@ async function startWebhook(
// Twilio sends application/x-www-form-urlencoded // Twilio sends application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({ extended: false })); app.use(bodyParser.urlencoded({ extended: false }));
app.post(path, async (req, res) => { app.post(path, async (req: Request, res: Response) => {
const { From, To, Body, MessageSid } = req.body ?? {}; const { From, To, Body, MessageSid } = req.body ?? {};
console.log(`[INBOUND] ${From} -> ${To} (${MessageSid}): ${Body}`); console.log(`[INBOUND] ${From} -> ${To} (${MessageSid}): ${Body}`);
@@ -269,31 +279,28 @@ async function startWebhook(
} }
async function getTailnetHostname() { async function getTailnetHostname() {
const { stdout } = await execFileAsync('tailscale', ['status', '--json'], { maxBuffer: 2_000_000 }); const { stdout } = await runExec('tailscale', ['status', '--json']);
const parsed = JSON.parse(stdout); const parsed = stdout ? (JSON.parse(stdout) as Record<string, unknown>) : {};
const dns = parsed?.Self?.DNSName as string | undefined; const self = parsed?.['Self'] as Record<string, unknown> | undefined;
const ips = parsed?.Self?.TailscaleIPs as string[] | undefined; const dns = typeof self?.['DNSName'] === 'string' ? (self['DNSName'] as string) : undefined;
const ips = Array.isArray(self?.['TailscaleIPs']) ? (self?.['TailscaleIPs'] as string[]) : [];
if (dns && dns.length > 0) return dns.replace(/\.$/, ''); if (dns && dns.length > 0) return dns.replace(/\.$/, '');
if (ips && ips.length > 0) return ips[0]; if (ips.length > 0) return ips[0];
throw new Error('Could not determine Tailscale DNS or IP'); throw new Error('Could not determine Tailscale DNS or IP');
} }
async function ensureFunnel(port: number) { async function ensureFunnel(port: number) {
try { try {
const status = await execFileAsync('tailscale', ['funnel', 'status', '--json'], { const statusOut = (await runExec('tailscale', ['funnel', 'status', '--json'])).stdout.trim();
maxBuffer: 2_000_000 const parsed = statusOut ? (JSON.parse(statusOut) as Record<string, unknown>) : {};
}).then((r) => r.stdout.trim()); if (!parsed || Object.keys(parsed).length === 0) {
if (!status || status === '{}' || status === 'null') {
console.error( console.error(
'Tailscale Funnel is not enabled on this tailnet/device. Enable it in the Tailscale admin console, then re-run warelay setup.' 'Tailscale Funnel is not enabled on this tailnet/device. Enable it in the Tailscale admin console, then re-run warelay setup.'
); );
process.exit(1); process.exit(1);
} }
const { stdout } = await execFileAsync('tailscale', ['funnel', '--yes', '--bg', `${port}`], { const { stdout } = await runExec('tailscale', ['funnel', '--yes', '--bg', `${port}`], 200_000);
maxBuffer: 200_000
});
if (stdout.trim()) console.log(stdout.trim()); if (stdout.trim()) console.log(stdout.trim());
} catch (err) { } catch (err) {
console.error('Failed to enable Tailscale Funnel. Is it allowed on your tailnet?', err); console.error('Failed to enable Tailscale Funnel. Is it allowed on your tailnet?', err);
@@ -302,20 +309,28 @@ async function ensureFunnel(port: number) {
} }
async function findWhatsappSenderSid(client: ReturnType<typeof createClient>, from: string) { async function findWhatsappSenderSid(client: ReturnType<typeof createClient>, from: string) {
const resp = await (client as any).request({ const resp = await (client as unknown as { request: (options: Record<string, unknown>) => Promise<{ data?: unknown }> }).request({
method: 'get', method: 'get',
uri: 'https://messaging.twilio.com/v2/Channels/Senders', uri: 'https://messaging.twilio.com/v2/Channels/Senders',
qs: { Channel: 'whatsapp', PageSize: 50 } qs: { Channel: 'whatsapp', PageSize: 50 }
}); });
const senders = resp?.data?.senders as Array<any>; const data = resp?.data as Record<string, unknown> | undefined;
if (!Array.isArray(senders)) { const senders = Array.isArray((data as Record<string, unknown> | undefined)?.senders)
? (data as { senders: unknown[] }).senders
: undefined;
if (!senders) {
throw new Error('Unable to list WhatsApp senders'); throw new Error('Unable to list WhatsApp senders');
} }
const match = senders.find((s) => s.sender_id === withWhatsAppPrefix(from)); const match = senders.find(
if (!match) { (s) =>
typeof s === 'object' &&
s !== null &&
(s as Record<string, unknown>).sender_id === withWhatsAppPrefix(from)
) as { sid?: string } | undefined;
if (!match || typeof match.sid !== 'string') {
throw new Error(`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`); throw new Error(`Could not find sender ${withWhatsAppPrefix(from)} in Twilio account`);
} }
return match.sid as string; return match.sid;
} }
async function updateWebhook( async function updateWebhook(