changemaker.lite/api/src/services/tailscale.client.ts

128 lines
3.5 KiB
TypeScript

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<void> {
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<T>(method: string, path: string): Promise<T> {
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<TailscaleDevice[]> {
const res = await this.request<{ devices?: TailscaleDevice[] }>(
'GET',
`/tailnet/${encodeURIComponent(this.tailnet)}/devices`,
);
return res.devices || [];
}
async getDevice(deviceId: string): Promise<TailscaleDevice> {
return this.request<TailscaleDevice>('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<boolean> {
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();