synology-cert-deploy¶
Modules
Push an externally-issued ACME certificate into a Synology DSM 7 NAS — automatically, on a timer, with a minimal blast radius if the pusher is ever compromised.
The problem¶
DSM 7 runs its own web server and ships no ACME client you can drive. You
can renew a cert for the NAS's hostname on some other machine you control
(NixOS security.acme, acme.sh, certbot, …), but then you're stuck doing
the last mile by hand: logging into DSM and re-uploading the PEM every ~60
days. Miss it and the appliance serves an expired cert.
This module closes that gap. It runs a small uploader against the DSM web API
(SYNO.Core.Certificate.import) that replaces the named cert in place, driven
by a daily systemd timer.
The insight / traps¶
-
Isolation is the design point, not an afterthought. Each NAS gets its own systemd service running as a unique
DynamicUserwhose only supplementary group is that cert's owning group, with the cert directory mounted read-only. So even if you push certs to several appliances from one host, a compromise of one pusher can read exactly one NAS's private key — never the others, never anything else on the box. Pair it with a per-cert group (e.g. NixOSsecurity.acme.certs.<name>.group). -
The password never touches argv or the environment. It's handed to the service via
LoadCredential, so it can't leak through/proc/<pid>/cmdline,ps, or an inherited environment. The uploader reads it from$CREDENTIALS_DIRECTORY/password. -
Idempotent by hash. The uploader sha256's the cert material and records it in a per-target state file. If nothing changed, it logs
skipand exits 0 — so a daily timer against a cert that renews every ~60 days is essentially free, yet re-pushes within a day of any renewal. -
descis dual-purpose. It's both the friendly name shown in DSM and the key used to locate the existing cert to replace. Get it wrong and you create a second cert instead of updating the one already bound to your services. Keep it stable. -
Jitter + catch-up.
RandomizedDelaySecspreads firings so several targets don't hammer their appliances in lockstep;Persistent=trueruns a push that was missed while the host was down. -
TLS to DSM is unverified by default — and that channel carries secrets. The initial cert on a fresh NAS is self-signed — the very thing you're replacing — so out of the box the uploader talks to the DSM API without verifying its cert. Be clear on what that costs: each run sends the DSM admin password and the private key over that connection, so anyone able to intercept the pusher↔NAS path (ARP/DNS spoof, rogue gateway, compromised switch) can impersonate the NAS, capture both, and return a fake success. The "trusted network path" guidance is therefore load-bearing, not optional. Once the NAS serves the publicly trusted cert this module installs (and
dsmUrluses a hostname that cert covers), setverifyTls = trueto verify against the system trust store and close the MITM window. -
Private-key filename varies. NixOS
security.acmewriteskey.pem;acme.sh/certbotwriteprivkey.pem. The uploader accepts either.chain.pemis optional.
Usage¶
Import the module and declare one entry per NAS:
{
imports = [ ./synology-cert-deploy ];
modules.synology-cert-deploy = {
enable = true;
hosts.nas = {
dsmUrl = "https://your-nas:5001";
passwordFile = "/run/secrets/nas-admin-password"; # a secret manager's decrypted path
certPath = "/var/lib/acme/nas.example.com";
certGroup = "nas-cert"; # the Unix group that owns the cert files
desc = "nas.example.com";
asDefault = true; # optional: make it DSM's default cert
};
};
}
On NixOS, arrange for the cert to exist and be group-readable — for example:
Options (per hosts.<name> entry)¶
| Option | Default | Meaning |
|---|---|---|
dsmUrl |
— | https://<host>:5001 — DSM web API base. |
user |
admin |
DSM admin account used to upload. |
passwordFile |
— | Path to a plain-text file with the DSM admin password (loaded via LoadCredential). |
certPath |
— | Directory with fullchain.pem + key.pem/privkey.pem (+ optional chain.pem). |
certGroup |
— | Unix group owning the cert files; the service's only supplementary group. |
desc |
— | Friendly name in DSM; also the key used to find the cert to replace. |
asDefault |
false |
Mark the imported cert as DSM's default. |
verifyTls |
false |
Verify the DSM cert against the system trust store before sending secrets. Leave off for the self-signed bootstrap; turn on once the NAS serves the installed cert. |
onCalendar |
daily |
Systemd OnCalendar for the timer. |
randomizedDelay |
1h |
RandomizedDelaySec jitter. |
Caveats¶
- Written for DSM 7. Earlier DSM versions use different API versions/paths.
- The admin account needs permission to import certificates. A dedicated, least-privileged admin account is preferable to your primary login.
- Force a run and watch it with
systemctl start synology-cert-deploy-<name> && journalctl -u synology-cert-deploy-<name> -e. - First run creates the cert if
descmatches nothing; delete the stray DSM-generated self-signed cert once yours is bound.
Source¶
modules/synology-cert-deploy/default.nix
# synology-cert-deploy — push externally-issued ACME certs into Synology DSM 7.
#
# DSM 7 runs its own web server and ships no ACME client you control. If your
# host already renews certs for the NAS hostname (NixOS security.acme, acme.sh,
# certbot, …), this module is the last mile: it pushes each renewed cert *into*
# the appliance over the DSM web API.
#
# Design point is blast-radius isolation. Each target is a unique DynamicUser
# whose ONLY supplementary group is that target's certGroup, with certPath
# mounted read-only — so a compromised push reads exactly one NAS's private key
# even when several targets share one host. The DSM password arrives via
# LoadCredential (never argv/env). A per-target sha256 state file makes the
# daily, jittered, Persistent timer a cheap no-op until the cert actually
# changes.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.modules.synology-cert-deploy;
inherit (lib)
mapAttrs'
nameValuePair
mkIf
mkOption
mkEnableOption
types
;
# The uploader is embedded so this module is a single-file drop-in.
# It talks SYNO.Core.Certificate.import and is idempotent: it hashes the
# cert material and skips the upload when nothing changed since last run.
uploader = pkgs.writeText "synology-cert-deploy.py" ''
#!/usr/bin/env python3
"""synology-cert-deploy - push an externally-issued ACME cert into a DSM 7
NAS via SYNO.Core.Certificate.import. Idempotent: skips upload if the cert
content hasn't changed since the last successful run.
Designed for unattended operation under a systemd service. No interactive
input. Reads:
--cert-dir DIR directory containing fullchain.pem, privkey.pem,
chain.pem (chain is optional)
--password-file PATH plain-text DSM admin password
--dsm-url URL https://host:5001
--user NAME DSM admin account
--desc NAME friendly name in DSM; also used to find existing
cert to replace (otherwise creates new)
--state-file PATH where to persist the sha256 of the last
successfully pushed cert; a mismatch/missing
triggers an upload, a match means skip
Exit codes: 0 success or skip-no-change; 1 push failed; 2 bad config.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import secrets as _secrets
import ssl
import sys
from pathlib import Path
from urllib import error, parse, request
# Set once from --verify-tls. Off by default: a fresh NAS serves the
# self-signed cert this tool is replacing, so verification can't succeed on
# the bootstrap run. Adopters whose NAS already serves the installed
# (publicly trusted) cert can turn it on to defeat a MITM on the push path.
VERIFY_TLS = False
def _ctx() -> ssl.SSLContext:
ctx = ssl.create_default_context()
if not VERIFY_TLS:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def post(url: str, sid: str | None, token: str | None, params: dict) -> dict:
if sid:
params = {**params, "_sid": sid}
body = parse.urlencode(params).encode()
req = request.Request(url, data=body, method="POST")
req.add_header("content-type", "application/x-www-form-urlencoded")
if token:
req.add_header("X-SYNO-TOKEN", token)
ctx = _ctx()
try:
with request.urlopen(req, context=ctx, timeout=30) as r:
return json.loads(r.read())
except error.HTTPError as e:
return {"success": False, "error": {"code": e.code, "msg": e.reason}}
def post_multipart(url: str, sid: str, token: str | None,
query: dict, fields: dict, files: dict) -> dict:
q = {**query, "_sid": sid}
full_url = f"{url}?{parse.urlencode(q)}"
boundary = "----syno_cert_" + _secrets.token_hex(12)
chunks: list[bytes] = []
for name, val in fields.items():
chunks.append(f"--{boundary}\r\n".encode())
chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
chunks.append(str(val).encode())
chunks.append(b"\r\n")
for name, (fname, content) in files.items():
chunks.append(f"--{boundary}\r\n".encode())
chunks.append(
f'Content-Disposition: form-data; name="{name}"; filename="{fname}"\r\n'.encode()
)
chunks.append(b"Content-Type: application/octet-stream\r\n\r\n")
chunks.append(content)
chunks.append(b"\r\n")
chunks.append(f"--{boundary}--\r\n".encode())
body = b"".join(chunks)
req = request.Request(full_url, data=body, method="POST")
req.add_header("content-type", f"multipart/form-data; boundary={boundary}")
if token:
req.add_header("X-SYNO-TOKEN", token)
ctx = _ctx()
try:
with request.urlopen(req, context=ctx, timeout=120) as r:
return json.loads(r.read())
except error.HTTPError as e:
return {"success": False, "error": {"code": e.code, "msg": e.reason}}
def log(msg: str) -> None:
print(msg, flush=True)
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--dsm-url", required=True)
p.add_argument("--user", required=True)
p.add_argument("--password-file", required=True)
p.add_argument("--cert-dir", required=True)
p.add_argument("--desc", required=True)
p.add_argument("--state-file", required=True)
p.add_argument("--default", action="store_true",
help="set as DSM default cert (services without a binding fall back to it)")
p.add_argument("--verify-tls", action="store_true",
help="verify the DSM cert against the system trust store "
"(only works once the NAS serves a publicly trusted cert)")
args = p.parse_args()
global VERIFY_TLS
VERIFY_TLS = args.verify_tls
cert_dir = Path(args.cert_dir)
fullchain = cert_dir / "fullchain.pem"
# NixOS security.acme writes the private key as `key.pem`; acme.sh /
# certbot use `privkey.pem`. Accept either.
privkey = cert_dir / "key.pem"
if not privkey.is_file():
privkey = cert_dir / "privkey.pem"
chain = cert_dir / "chain.pem"
for required in (fullchain, privkey):
if not required.is_file():
log(f"missing required file: {required}")
return 2
cert_pem = fullchain.read_bytes()
key_pem = privkey.read_bytes()
chain_pem = chain.read_bytes() if chain.is_file() else None
h = hashlib.sha256()
h.update(cert_pem); h.update(b"\0")
h.update(key_pem); h.update(b"\0")
if chain_pem is not None:
h.update(chain_pem)
digest = h.hexdigest()
state_path = Path(args.state_file)
if state_path.is_file():
prev = state_path.read_text().strip()
if prev == digest:
log(f"unchanged ({digest[:12]}), skip")
return 0
password = Path(args.password_file).read_text().rstrip("\n")
url = args.dsm_url.rstrip("/") + "/webapi/entry.cgi"
login = post(url, None, None, {
"api": "SYNO.API.Auth", "method": "login", "version": "7",
"account": args.user, "passwd": password,
"session": "SynoCertDeploy", "format": "sid",
"enable_syno_token": "yes",
})
if not login.get("success"):
log(f"login failed: {login.get('error')}")
return 1
sid = login["data"]["sid"]
token = login["data"].get("synotoken")
try:
listing = post(url, sid, token, {
"api": "SYNO.Core.Certificate.CRT", "method": "list", "version": "1",
})
if not listing.get("success"):
log(f"cert list failed: {listing.get('error')}")
return 1
target_id = ""
for c in listing.get("data", {}).get("certificates", []):
if c.get("desc") == args.desc:
target_id = c.get("id", "")
log(f"replacing existing cert id={target_id} desc={args.desc!r}")
break
if not target_id:
log(f"no existing cert with desc={args.desc!r}; uploading new")
files = {
"cert": ("fullchain.pem", cert_pem),
"key": ("privkey.pem", key_pem),
}
if chain_pem is not None:
files["inter_cert"] = ("chain.pem", chain_pem)
fields = {
"id": target_id,
"desc": args.desc,
"as_default": "true" if args.default else "",
}
r = post_multipart(
url, sid, token,
{"api": "SYNO.Core.Certificate", "method": "import", "version": "1"},
fields, files,
)
if not r.get("success"):
log(f"import failed: {r.get('error')}")
return 1
new_id = (r.get("data") or {}).get("id") or target_id or "?"
log(f"OK pushed cert (id={new_id}, sha256={digest[:12]})")
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(digest + "\n")
return 0
finally:
post(url, sid, token, {
"api": "SYNO.API.Auth", "method": "logout", "version": "7",
"session": "SynoCertDeploy",
})
if __name__ == "__main__":
sys.exit(main())
'';
in
{
options.modules.synology-cert-deploy = {
enable = mkEnableOption "push externally-issued ACME certs to Synology DSM 7";
hosts = mkOption {
default = { };
description = ''
Map of <name> -> push-target config. Each entry creates one systemd
service + one daily timer. Each runs as a unique DynamicUser with
SupplementaryGroups=[<certGroup>], so a service can only read its own
cert files; cross-host blast radius stays at one NAS.
'';
type = types.attrsOf (
types.submodule {
options = {
dsmUrl = mkOption {
type = types.str;
example = "https://your-nas:5001";
description = "https://<host>:5001 — DSM web API base.";
};
user = mkOption {
type = types.str;
default = "admin";
description = "DSM admin account used to upload the cert.";
};
passwordFile = mkOption {
type = types.path;
example = "/run/secrets/nas-admin-password";
description = ''
Path to a plain-text file holding the DSM admin password.
Point this at a secret manager's decrypted path (agenix,
sops-nix, systemd-creds, …). It is read into a systemd
credential, never passed on argv or in the environment.
'';
};
certPath = mkOption {
type = types.str;
example = "/var/lib/acme/nas.example.com";
description = ''
Directory holding fullchain.pem plus key.pem or privkey.pem
(chain.pem optional). For NixOS security.acme this is
/var/lib/acme/<cert-name>.
'';
};
certGroup = mkOption {
type = types.str;
example = "nas-cert";
description = ''
The Unix group that owns the cert files in certPath. The deploy
service joins this group as its only supplementary group, so it
can read this one cert's privkey and nothing else. For NixOS
security.acme, set the cert's `group` to a per-NAS group and
pass the same name here.
'';
};
desc = mkOption {
type = types.str;
example = "nas.example.com";
description = "Friendly name shown in DSM and used to find the existing cert to replace.";
};
asDefault = mkOption {
type = types.bool;
default = false;
description = "Mark the imported cert as DSM's default.";
};
verifyTls = mkOption {
type = types.bool;
default = false;
description = ''
Verify the DSM server certificate against the system trust store
before sending the admin password and private key. Defaults to
false because a fresh NAS serves the self-signed cert this module
is replacing, so verification cannot succeed on the bootstrap
run. Once the NAS serves the (publicly trusted) cert this module
installs — and dsmUrl uses a hostname that cert covers — set this
to true to defeat a man-in-the-middle on the push path.
'';
};
onCalendar = mkOption {
type = types.str;
default = "daily";
description = "Systemd OnCalendar expression for the timer.";
};
randomizedDelay = mkOption {
type = types.str;
default = "1h";
description = "Spread the timer firings to avoid thundering-herd against the NAS.";
};
};
}
);
};
};
config = mkIf (cfg.enable && cfg.hosts != { }) {
systemd.tmpfiles.rules = [
"d /var/lib/synology-cert-deploy 0755 root root - -"
];
systemd.services = mapAttrs' (
name: host:
nameValuePair "synology-cert-deploy-${name}" {
description = "Push ACME cert to Synology ${name} (${host.dsmUrl})";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
DynamicUser = true;
SupplementaryGroups = [ host.certGroup ];
StateDirectory = "synology-cert-deploy/${name}";
StateDirectoryMode = "0700";
LoadCredential = "password:${host.passwordFile}";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
SystemCallArchitectures = "native";
ReadOnlyPaths = [ host.certPath ];
ExecStart = lib.concatStringsSep " " [
"${pkgs.python3}/bin/python3"
"${uploader}"
"--dsm-url ${lib.escapeShellArg host.dsmUrl}"
"--user ${lib.escapeShellArg host.user}"
''--password-file "''${CREDENTIALS_DIRECTORY}/password"''
"--cert-dir ${lib.escapeShellArg host.certPath}"
"--desc ${lib.escapeShellArg host.desc}"
"--state-file /var/lib/synology-cert-deploy/${name}/last-sha256"
(lib.optionalString host.asDefault "--default")
(lib.optionalString host.verifyTls "--verify-tls")
];
};
}
) cfg.hosts;
systemd.timers = mapAttrs' (
name: host:
nameValuePair "synology-cert-deploy-${name}" {
description = "Periodic cert push for Synology ${name}";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = host.onCalendar;
Persistent = true;
RandomizedDelaySec = host.randomizedDelay;
Unit = "synology-cert-deploy-${name}.service";
};
}
) cfg.hosts;
};
}