import { logger } from '../utils/logger'; // --- Types --- export interface TailscaleDevice { id: string; name: string; // FQDN hostname addresses: string[]; // IPv4 + IPv6 Tailscale addresses os: string; // "android", "linux", "windows", etc. hostname: string; // Short hostname online: boolean; lastSeen: string; // ISO 8601 clientVersion?: string; updateAvailable?: boolean; tags?: string[]; } // --- Client --- class TailscaleClient { private apiKey = ''; private tailnet = '-'; // "-" = default tailnet (Tailscale API convention) private get baseUrl(): string { return 'https://api.tailscale.com/api/v2'; } /** Configure client with an API key (and optional tailnet override). */ configure(apiKey: string, tailnet?: string): void { this.apiKey = apiKey; if (tailnet) this.tailnet = tailnet; } /** Load configuration from DB settings. */ async configureFromDb(): Promise { const { siteSettingsService } = await import('../modules/settings/settings.service'); const settings = await siteSettingsService.get(); this.apiKey = settings.smsTailscaleApiKey || ''; if (settings.smsTailscaleTailnet) { this.tailnet = settings.smsTailscaleTailnet; } } get configured(): boolean { return !!this.apiKey; } private async request(method: string, path: string): Promise { if (!this.apiKey) { throw new Error('Tailscale API not configured. Provide an API key.'); } const url = `${this.baseUrl}${path}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { const res = await fetch(url, { method, headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', }, signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Tailscale API ${method} ${path} returned ${res.status}: ${text}`); } const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return await res.json() as T; } return {} as T; } finally { clearTimeout(timeout); } } // --- Devices --- async listDevices(): Promise { const res = await this.request<{ devices?: TailscaleDevice[] }>( 'GET', `/tailnet/${encodeURIComponent(this.tailnet)}/devices`, ); return res.devices || []; } async getDevice(deviceId: string): Promise { return this.request('GET', `/device/${encodeURIComponent(deviceId)}`); } // --- Helpers --- /** Filter devices by Android OS. */ filterAndroidDevices(devices: TailscaleDevice[]): TailscaleDevice[] { return devices.filter(d => d.os.toLowerCase() === 'android'); } /** Extract the first IPv4 address from a device's address list. */ getDeviceIpv4(device: TailscaleDevice): string | null { for (const addr of device.addresses) { // IPv4 addresses don't contain ':' if (!addr.includes(':')) { return addr; } } return null; } /** Quick health check: tries to list devices. */ async healthCheck(): Promise { try { await this.listDevices(); return true; } catch (err) { logger.warn('Tailscale health check failed:', err instanceof Error ? err.message : err); return false; } } } export { TailscaleClient }; export const tailscaleClient = new TailscaleClient();