128 lines
3.5 KiB
TypeScript
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();
|