Add Synology integration — completes all six planned live integrations

Sixth and final live integration: volume and disk health across the NAS,
read-only per the delivery plan (DSM write actions are riskier and stayed
out of scope). Adapter built against the same discover-then-call pattern
used by hacf-fr/synologydsm-api (the library behind Home Assistant's
Synology integration) since DSM's API paths/versions vary by release and
the user's NAS isn't reachable from here to check directly:
- GET query.cgi?api=SYNO.API.Info&method=query&query=all once per adapter
  instance, to learn the real path/version for SYNO.API.Auth and
  SYNO.Storage.CGI.Storage rather than hardcoding them
- SYNO.API.Auth login (account/passwd/format=sid) for a session id, re-used
  across calls and refreshed on session-related error codes (105/106/119)
- SYNO.Storage.CGI.Storage's `load_info` method, which returns disks,
  volumes, and pools in one call — verified against that library's
  storage.py field mapping (size.total/used, status, smart_status, temp,
  exceed_bad_sector_thr, below_remain_life_thr)

Uses node:https directly (like Proxmox and the cPanel DNS adapter) for an
"allow self-signed certificate" option, since DSM ships one by default.
No 2FA support — login surfaces a clear error if the account requires it
rather than failing silently.

Verified: full build passes. Unreachable from here, so ran an 11-check HTTP
test against the live server: confirms all six integration types are now
registered, role gating, credential (password) non-leakage, disabled-
integration blocking, and the wrong_type crash-safety check — server stays
up throughout. Real volume/disk data still needs verification once this app
can reach the user's NAS.

This completes the integrations phase from the original plan: Tailscale,
Gitea, Dockhand, Semaphore, Proxmox, Synology are all built, each following
the same config-in-UI + encrypted-credentials pattern. Combined with the
foundation, Secrets, IPAM, DNS, and Servers & Tasks modules from earlier,
Homelab Manager now covers every feature area from the user's original
request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 00:35:21 +02:00
1 parent ae39f18755
commit a83a4b3b11
7 files changed
+465 -1

No files matched your search

+6
View File
@@ -33,6 +33,12 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
synology: [
{ key: "url", label: "Synology DSM URL", secret: false, placeholder: "https://nas.example.lan:5001" },
{ key: "username", label: "Username", secret: false },
{ key: "password", label: "Password", secret: true, type: "password" },
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
],
};
/** Fixed base URL per integration type, stored on the row for display/reference. */
+3
View File
@@ -5,6 +5,7 @@ import { createGiteaAdapter } from "./gitea/adapter.js";
import { createDockhandAdapter } from "./dockhand/adapter.js";
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
import { createProxmoxAdapter } from "./proxmox/adapter.js";
import { createSynologyAdapter } from "./synology/adapter.js";
export interface PingableAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
@@ -29,6 +30,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
return createSemaphoreAdapter(config as any);
case "proxmox":
return createProxmoxAdapter(config as any);
case "synology":
return createSynologyAdapter(config as any);
default:
throw new Error(`Integration type "${type}" is not implemented yet`);
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Synology DSM adapter — uses the DSM Web API (webapi/*.cgi).
* Requires config: url, username, password; optional: insecure
*
* Read-only by design (per the delivery plan — DSM write actions are riskier
* and out of scope for v1): reports volume/pool usage and disk health only.
*
* DSM's API paths and versions vary by DSM release, so — like every
* well-behaved DSM client (this follows the same discover-then-call pattern
* as hacf-fr/synologydsm-api, the library behind Home Assistant's Synology
* integration) — this first queries SYNO.API.Info to learn the real path and
* version for SYNO.API.Auth and SYNO.Storage.CGI.Storage rather than
* hardcoding them. SYNO.Storage.CGI.Storage's `load_info` method returns
* disks, volumes, and storage pools in one call (verified against that
* library's storage.py wrapper).
*
* DSM ships with a self-signed certificate unless the admin configured a
* real one, so (like the cPanel/Proxmox adapters) this uses node:https
* directly to support an "insecure" opt-out of certificate verification.
*/
import * as https from "node:https";
export interface SynologyConfig {
url: string;
username: string;
password: string;
insecure?: boolean;
}
export interface SynologyVolume {
id: string;
status: string; // "normal" | "degraded" | "crashed" | ...
deviceType: string;
sizeTotal: number | null;
sizeUsed: number | null;
}
export interface SynologyDisk {
id: string;
name: string;
device: string;
status: string; // "normal" | "system_partition_failed" | "crashed" | ...
smartStatus: string;
temp: number | null;
exceedBadSectorThreshold: boolean;
belowRemainLifeThreshold: boolean;
}
export interface SynologyStorageInfo {
volumes: SynologyVolume[];
disks: SynologyDisk[];
}
export interface SynologyAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
getStorageInfo(): Promise<SynologyStorageInfo>;
}
interface RawResponse {
status: number;
text: () => string;
}
function request(url: string, insecure: boolean): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = https.request(
{
hostname: parsed.hostname,
port: parsed.port || 5001,
path: parsed.pathname + parsed.search,
method: "GET",
rejectUnauthorized: !insecure,
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
},
);
req.on("error", reject);
req.end();
});
}
interface ApiInfo {
path: string;
maxVersion: number;
}
export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter {
const insecure = config.insecure === true;
let apiMap: Record<string, ApiInfo> | null = null;
let sid: string | null = null;
function base() {
return config.url.replace(/\/$/, "");
}
async function rawGet(path: string, params: Record<string, string | number>): Promise<any> {
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString();
const res = await request(`${base()}/webapi/${path}?${qs}`, insecure);
let data: any;
try {
data = JSON.parse(res.text());
} catch {
throw new Error(`Synology API returned non-JSON response (HTTP ${res.status})`);
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Synology API error: HTTP ${res.status}`);
}
return data;
}
async function discoverApis(): Promise<Record<string, ApiInfo>> {
if (apiMap) return apiMap;
const data = await rawGet("query.cgi", { api: "SYNO.API.Info", version: 1, method: "query", query: "all" });
if (!data.success) throw new Error(data.error?.code ? `Synology API.Info error ${data.error.code}` : "Synology API.Info query failed");
apiMap = data.data;
return apiMap!;
}
async function login(): Promise<string> {
const apis = await discoverApis();
const authInfo = apis["SYNO.API.Auth"];
if (!authInfo) throw new Error("SYNO.API.Auth not available on this DSM instance");
const data = await rawGet(authInfo.path, {
api: "SYNO.API.Auth",
version: authInfo.maxVersion,
method: "login",
account: config.username,
passwd: config.password,
format: "sid",
});
if (!data.success) {
const code = data.error?.code;
const messages: Record<number, string> = {
400: "Invalid username or password",
401: "Account disabled",
402: "Permission denied",
403: "2-factor authentication is required — not supported by this integration",
404: "2-factor authentication code was rejected",
};
throw new Error((code && messages[code]) || `Synology login failed (error ${code ?? "unknown"})`);
}
sid = data.data.sid;
return sid!;
}
async function callApi(apiName: string, method: string, extraParams: Record<string, string | number> = {}): Promise<any> {
const apis = await discoverApis();
const info = apis[apiName];
if (!info) throw new Error(`${apiName} is not available on this DSM instance`);
if (!sid) await login();
const doCall = async () =>
rawGet(info.path, { api: apiName, version: info.maxVersion, method, _sid: sid!, ...extraParams });
let data = await doCall();
// Session-related error codes (105 permission/session, 106 session timeout,
// 119 invalid session) -> re-login once and retry.
if (!data.success && [105, 106, 119].includes(data.error?.code)) {
sid = null;
await login();
data = await doCall();
}
if (!data.success) {
throw new Error(`${apiName} error ${data.error?.code ?? "unknown"}`);
}
return data.data;
}
async function getStorageInfo(): Promise<SynologyStorageInfo> {
const data = await callApi("SYNO.Storage.CGI.Storage", "load_info");
const volumes: SynologyVolume[] = (data.volumes ?? []).map((v: any) => ({
id: v.id,
status: v.status,
deviceType: v.device_type,
sizeTotal: v.size?.total !== undefined ? Number(v.size.total) : null,
sizeUsed: v.size?.used !== undefined ? Number(v.size.used) : null,
}));
const disks: SynologyDisk[] = (data.disks ?? []).map((d: any) => ({
id: d.id,
name: d.name,
device: d.device,
status: d.status,
smartStatus: d.smart_status,
temp: d.temp ?? null,
exceedBadSectorThreshold: !!d.exceed_bad_sector_thr,
belowRemainLifeThreshold: !!d.below_remain_life_thr,
}));
return { volumes, disks };
}
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await login();
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return { ping, getStorageInfo };
}
+42
View File
@@ -19,6 +19,7 @@ import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
import { createSynologyAdapter } from "../integrations/synology/adapter.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const integrationsRouter = Router();
@@ -614,3 +615,44 @@ for (const action of proxmoxActions) {
}),
);
}
// ─── Synology ────────────────────────────────────────────────────────────────
// Read-only per the delivery plan — no start/stop/etc actions.
async function requireSynologyAdapter(req: Request, res: Response) {
const id = Number(req.params.id);
const loaded = await loadIntegrationConfig(id);
if (!loaded) {
res.status(404).json({ error: "not_found" });
return null;
}
if (loaded.integration.type !== "synology") {
res.status(400).json({ error: "wrong_type" });
return null;
}
if (!loaded.integration.enabled) {
res.status(400).json({ error: "integration_disabled" });
return null;
}
return { integration: loaded.integration, adapter: createSynologyAdapter(loaded.config as any) };
}
integrationsRouter.get("/:id/synology/storage", asyncHandler(async (req, res) => {
const found = await requireSynologyAdapter(req, res);
if (!found) return;
try {
const info = await found.adapter.getStorageInfo();
res.json({
...info,
summary: {
volumeCount: info.volumes.length,
volumesNotNormal: info.volumes.filter((v) => v.status !== "normal").length,
diskCount: info.disks.length,
disksNotNormal: info.disks.filter((d) => d.status !== "normal").length,
},
});
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));