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

+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) });
}
}));