Files
homarr/packages/integrations/src/synology/synology-client.ts
T

410 lines
13 KiB
TypeScript

import type { Headers } from "undici";
import type { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { SessionStore } from "../base/session-store";
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
import {
AUTH_API_NAME,
INFO_API_NAME,
REQUEST_TIMEOUT_MS,
SESSION_ERROR_CODES,
synologyApiInfoResponseSchema,
synologyAuthResponseSchema,
synologyEnvelopeSchema,
synologyLegacyStorageDataSchema,
synologySmartInfoDataSchema,
synologyStorageLoadInfoDataSchema,
synologyStorageV2DataSchema,
synologySystemInfoDataSchema,
synologySystemStatusDataSchema,
synologyUpgradeCheckDataSchema,
synologyUpgradeStatusDataSchema,
synologyUtilizationDataSchema,
} from "./synology-types";
const logger = createLogger({ module: "synologyClient" });
const AUTH_SESSION_NAME = "DownloadStation";
type ApiDefinition = {
cgiPath: string;
maxVersion: number;
};
type StoredSession = {
cookieHeader: string;
};
type SynologyClientOptions = {
integrationId: string;
baseUrl: string;
username: string;
password: string;
sessionStore: SessionStore<StoredSession>;
};
const apiDefinitionCache = new Map<string, ApiDefinition>();
export class SynologyClient {
private readonly integrationId: string;
private readonly baseUrl: string;
private readonly username: string;
private readonly password: string;
private readonly sessionStore: SessionStore<StoredSession>;
constructor(options: SynologyClientOptions) {
this.integrationId = options.integrationId;
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.username = options.username;
this.password = options.password;
this.sessionStore = options.sessionStore;
}
public async getSystemInfoAsync() {
const response = await this.requestAsync("SYNO.Core.System", { method: "info" });
return synologySystemInfoDataSchema.parse(response.data);
}
public async getUtilizationAsync() {
const response = await this.requestAsync("SYNO.Core.System.Utilization", { method: "get" });
return synologyUtilizationDataSchema.parse(response.data);
}
public async getStorageVolumesAsync(): Promise<SynologyVolumeRecord[]> {
try {
const storageV2Response = await this.requestAsync("SYNO.Core.System", {
method: "info",
extraParams: { type: "storage_v2" },
});
const storageV2 = synologyStorageV2DataSchema.parse(storageV2Response.data);
const volumesFromV2 = this.mapStorageV2Volumes(storageV2);
if (volumesFromV2.length > 0) {
return volumesFromV2;
}
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
if (legacyVolumesFromV2.length > 0) {
return legacyVolumesFromV2;
}
} catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId,
error,
});
}
const legacyResponse = await this.requestAsync("SYNO.Core.System", {
method: "info",
extraParams: { type: "storage" },
});
const legacyStorage = synologyLegacyStorageDataSchema.parse(legacyResponse.data);
return this.mapLegacyVolumes(legacyStorage.vol_info ?? []);
}
public async getStorageLoadInfoAsync() {
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Storage", { method: "load_info" });
if (!response) {
return null;
}
return synologyStorageLoadInfoDataSchema.parse(response.data);
}
public async getSmartInfoAsync() {
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Smart", { method: "get_smart_info" });
if (!response) {
return null;
}
return synologySmartInfoDataSchema.parse(response.data);
}
public async getSystemStatusAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.System.Status", { method: "get" });
if (!response) {
return null;
}
return synologySystemStatusDataSchema.parse(response.data);
}
public async getUpgradeStatusAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade", { method: "status" });
if (!response) {
return null;
}
return synologyUpgradeStatusDataSchema.parse(response.data);
}
public async getUpgradeCheckAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade.Server", { method: "check" });
if (!response) {
return null;
}
return synologyUpgradeCheckDataSchema.parse(response.data);
}
public async testConnectionAsync() {
await this.getSystemInfoAsync();
}
private mapLegacyVolumes(volumes: z.infer<typeof synologyLegacyStorageDataSchema>["vol_info"]) {
return (volumes ?? [])
.map((volume) => this.mapVolumeRecord(volume.name, volume.used_size, volume.total_size, volume.status))
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
private mapStorageV2Volumes(storage: z.infer<typeof synologyStorageV2DataSchema>) {
return (storage.volumes ?? [])
.map((volume) => {
const name = volume.id ?? volume.vol_path ?? volume.display_name;
if (!name) {
return null;
}
return this.mapVolumeRecord(name, volume.size?.used, volume.size?.total, volume.status, volume.display_name);
})
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
private mapVolumeRecord(
name: string,
usedValue: number | string | undefined,
totalValue: number | string | undefined,
status: string | undefined,
displayName?: string,
): SynologyVolumeRecord | null {
const usedBytes = parseNumericValue(usedValue);
const totalBytes = parseNumericValue(totalValue);
if (usedBytes === null || totalBytes === null || totalBytes <= 0) {
return null;
}
return {
name,
displayName: displayName && displayName !== name ? displayName : undefined,
usedBytes,
totalBytes,
status,
};
}
public mapDisksFromLoadInfo(loadInfo: z.infer<typeof synologyStorageLoadInfoDataSchema>): SynologyDiskRecord[] {
return (loadInfo.disks ?? []).map((disk) => ({
identifier: disk.id ?? disk.name ?? disk.display_name ?? "unknown-disk",
name: disk.display_name ?? disk.name ?? disk.id ?? "unknown-disk",
status: disk.smart_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
volumeName: disk.volume_id ?? disk.vol_path,
}));
}
public mapDisksFromSmartInfo(smartInfo: z.infer<typeof synologySmartInfoDataSchema>): SynologyDiskRecord[] {
const disks = smartInfo.disks ?? smartInfo.items ?? [];
return disks.map((disk) => ({
identifier: disk.disk_id ?? disk.id ?? disk.name ?? "unknown-disk",
name: disk.name ?? disk.disk_id ?? disk.id ?? "unknown-disk",
status: disk.overall_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
volumeName: undefined,
}));
}
private async requestOptionalAsync(
apiName: string,
options: { method: string; extraParams?: Record<string, string> },
) {
try {
return await this.requestAsync(apiName, options);
} catch (error) {
logger.debug("Optional Synology API request failed", {
integrationId: this.integrationId,
apiName,
method: options.method,
error,
});
return null;
}
}
private async requestAsync(
apiName: string,
options: { method: string; extraParams?: Record<string, string> },
): Promise<z.infer<typeof synologyEnvelopeSchema>> {
return await this.withAuthenticatedSessionAsync(async (session) => {
const apiDefinition = await this.getApiDefinitionAsync(apiName);
const requestUrl = new URL(`${this.baseUrl}/webapi/${apiDefinition.cgiPath}`);
requestUrl.searchParams.set("api", apiName);
requestUrl.searchParams.set("version", String(apiDefinition.maxVersion));
requestUrl.searchParams.set("method", options.method);
for (const [key, value] of Object.entries(options.extraParams ?? {})) {
requestUrl.searchParams.set(key, value);
}
const response = await fetchWithTrustedCertificatesAsync(requestUrl, {
headers: {
Cookie: session.cookieHeader,
"User-Agent": "Homarr",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError(response);
}
const payload = synologyEnvelopeSchema.parse(await response.json());
if (!payload.success) {
const errorCode = payload.error?.code;
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
throw new SynologySessionError(errorCode);
}
throw new ResponseError({
status: 502,
url: requestUrl.toString(),
});
}
return payload;
});
}
private async withAuthenticatedSessionAsync<T>(callback: (session: StoredSession) => Promise<T>): Promise<T> {
const storedSession = await this.sessionStore.getAsync();
if (storedSession) {
try {
return await callback(storedSession);
} catch (error) {
if (!(error instanceof SynologySessionError)) {
throw error;
}
logger.debug("Synology session expired, re-authenticating", {
integrationId: this.integrationId,
errorCode: error.errorCode,
});
await this.sessionStore.clearAsync();
}
}
const session = await this.loginAsync();
await this.sessionStore.setAsync(session);
return await callback(session);
}
private async loginAsync(): Promise<StoredSession> {
const authDefinition = await this.getApiDefinitionAsync(AUTH_API_NAME);
const loginUrl = new URL(`${this.baseUrl}/webapi/${authDefinition.cgiPath}`);
loginUrl.searchParams.set("api", AUTH_API_NAME);
loginUrl.searchParams.set("version", String(authDefinition.maxVersion));
loginUrl.searchParams.set("method", "login");
const safeLoginUrl = loginUrl.toString();
const response = await fetchWithTrustedCertificatesAsync(loginUrl, {
method: "POST",
headers: {
"User-Agent": "Homarr",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
account: this.username,
passwd: this.password,
session: AUTH_SESSION_NAME,
format: "cookie",
}).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError({ status: response.status, url: safeLoginUrl });
}
const payload = synologyAuthResponseSchema.parse(await response.json());
if (!payload.success) {
throw new ResponseError({
status: 401,
url: safeLoginUrl,
});
}
const cookieHeader = extractCookieHeader(response.headers);
if (!cookieHeader) {
throw new ResponseError({
status: 401,
url: safeLoginUrl,
});
}
return { cookieHeader };
}
private async getApiDefinitionAsync(apiName: string): Promise<ApiDefinition> {
const cacheKey = `${this.integrationId}:${apiName}`;
const cachedDefinition = apiDefinitionCache.get(cacheKey);
if (cachedDefinition) {
return cachedDefinition;
}
const infoUrl = new URL(`${this.baseUrl}/webapi/query.cgi`);
infoUrl.searchParams.set("api", INFO_API_NAME);
infoUrl.searchParams.set("version", "1");
infoUrl.searchParams.set("method", "query");
infoUrl.searchParams.set("query", apiName);
const response = await fetchWithTrustedCertificatesAsync(infoUrl, {
headers: {
"User-Agent": "Homarr",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError(response);
}
const payload = synologyApiInfoResponseSchema.parse(await response.json());
const apiDefinition = payload.data?.[apiName];
if (!payload.success || !apiDefinition) {
throw new ResponseError({
status: 502,
url: infoUrl.toString(),
});
}
const definition = {
cgiPath: apiDefinition.path,
maxVersion: apiDefinition.maxVersion,
};
apiDefinitionCache.set(cacheKey, definition);
return definition;
}
}
class SynologySessionError extends Error {
constructor(public readonly errorCode: number) {
super(`Synology session error ${String(errorCode)}`);
this.name = SynologySessionError.name;
}
}
function extractCookieHeader(headers: Headers): string | null {
const cookies = headers.getSetCookie();
if (cookies.length === 0) {
return null;
}
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
}
function parseNumericValue(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseOptionalNumericValue(value: number | string | undefined): number | null {
const parsed = parseNumericValue(value);
return parsed === null ? null : parsed;
}