1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.cloudflare-ddns;
9
10 boolToString = b: if b then "true" else "false";
11 formatList = l: lib.concatStringsSep "," l;
12 formatDuration = d: d.String;
13in
14{
15 options.services.cloudflare-ddns = {
16 enable = lib.mkEnableOption "Cloudflare Dynamic DNS service";
17
18 package = lib.mkPackageOption pkgs "cloudflare-ddns" { };
19
20 credentialsFile = lib.mkOption {
21 type = lib.types.path;
22 description = ''
23 Path to a file containing the Cloudflare API authentication token.
24 The file content should be in the format `CLOUDFLARE_API_TOKEN=YOUR_SECRET_TOKEN`.
25 The service user `${cfg.user}` needs read access to this file.
26 Ensure permissions are secure (e.g., `0400` or `0440`) and ownership is appropriate
27 (e.g., `owner = root`, `group = ${cfg.group}`).
28 Using `CLOUDFLARE_API_TOKEN` is preferred over the deprecated `CF_API_TOKEN`.
29 '';
30 example = "/run/secrets/cloudflare-ddns-token";
31 };
32
33 domains = lib.mkOption {
34 type = lib.types.listOf lib.types.str;
35 default = [ ];
36 description = ''
37 List of domain names (FQDNs) to manage. Wildcards like `*.example.com` are supported.
38 These domains will be managed for both IPv4 and IPv6 unless overridden by
39 `ip4Domains` or `ip6Domains`, or if the respective providers are disabled.
40 This corresponds to the `DOMAINS` environment variable.
41 '';
42 example = [
43 "home.example.com"
44 "*.dynamic.example.org"
45 ];
46 };
47
48 ip4Domains = lib.mkOption {
49 type = lib.types.nullOr (lib.types.listOf lib.types.str);
50 default = null;
51 description = ''
52 Explicit list of domains to manage only for IPv4. If set, overrides `domains` for IPv4.
53 Corresponds to the `IP4_DOMAINS` environment variable.
54 '';
55 example = [ "ipv4.example.com" ];
56 };
57
58 ip6Domains = lib.mkOption {
59 type = lib.types.nullOr (lib.types.listOf lib.types.str);
60 default = null;
61 description = ''
62 Explicit list of domains to manage only for IPv6. If set, overrides `domains` for IPv6.
63 Corresponds to the `IP6_DOMAINS` environment variable.
64 '';
65 example = [ "ipv6.example.com" ];
66 };
67
68 wafLists = lib.mkOption {
69 type = lib.types.listOf lib.types.str;
70 default = [ ];
71 description = ''
72 List of WAF IP Lists to manage, in the format `account-id/list-name`.
73 (Experimental feature as of cloudflare-ddns 1.14.0).
74 '';
75 example = [ "YOUR_ACCOUNT_ID/allowed_dynamic_ips" ];
76 };
77
78 provider = {
79 ipv4 = lib.mkOption {
80 type = lib.types.str;
81 default = "cloudflare.trace";
82 description = ''
83 IP detection provider for IPv4. Common values: `cloudflare.trace`, `cloudflare.doh`, `local`, `url:URL`, `none`.
84 Use `none` to disable IPv4 updates.
85 See cloudflare-ddns documentation for all options.
86 '';
87 };
88 ipv6 = lib.mkOption {
89 type = lib.types.str;
90 default = "cloudflare.trace";
91 description = ''
92 IP detection provider for IPv6. Common values: `cloudflare.trace`, `cloudflare.doh`, `local`, `url:URL`, `none`.
93 Use `none` to disable IPv6 updates.
94 See cloudflare-ddns documentation for all options.
95 '';
96 };
97 };
98
99 updateCron = lib.mkOption {
100 type = lib.types.str;
101 default = "@every 5m";
102 description = ''
103 Cron expression for how often to check and update IPs.
104 Use "@once" to run only once and then exit.
105 '';
106 example = "@hourly";
107 };
108
109 updateOnStart = lib.mkOption {
110 type = lib.types.bool;
111 default = true;
112 description = "Whether to perform an update check immediately on service start.";
113 };
114
115 deleteOnStop = lib.mkOption {
116 type = lib.types.bool;
117 default = false;
118 description = ''
119 Whether to delete the managed DNS records and clear WAF lists when the service is stopped gracefully.
120 Warning: Setting this to true with `updateCron = "@once"` will cause immediate deletion.
121 '';
122 };
123
124 cacheExpiration = lib.mkOption {
125 type = lib.types.str;
126 default = "6h";
127 description = ''
128 Duration for which API responses (like Zone ID, Record IDs) are cached.
129 Uses Go's duration format (e.g., "6h", "1h30m").
130 '';
131 };
132
133 ttl = lib.mkOption {
134 type = lib.types.ints.positive;
135 default = 1;
136 description = ''
137 Time To Live (TTL) for the DNS records in seconds.
138 Must be 1 (for automatic) or between 30 and 86400.
139 '';
140 };
141
142 proxied = lib.mkOption {
143 type = lib.types.str;
144 default = "false";
145 description = ''
146 Whether the managed DNS records should be proxied through Cloudflare ('orange cloud').
147 Accepts boolean values (`true`, `false`) or a domain expression.
148 See cloudflare-ddns documentation for expression syntax (e.g., "is(a.com) || sub(b.org)").
149 '';
150 example = "true";
151 };
152
153 recordComment = lib.mkOption {
154 type = lib.types.str;
155 default = "";
156 description = "Comment to add to managed DNS records.";
157 };
158
159 wafListDescription = lib.mkOption {
160 type = lib.types.str;
161 default = "";
162 description = "Description for managed WAF lists (used when creating or verifying lists).";
163 };
164
165 detectionTimeout = lib.mkOption {
166 type = lib.types.str;
167 default = "5s";
168 description = "Timeout for detecting the public IP address.";
169 };
170
171 updateTimeout = lib.mkOption {
172 type = lib.types.str;
173 default = "30s";
174 description = "Timeout for updating records via the Cloudflare API.";
175 };
176
177 healthchecks = lib.mkOption {
178 type = lib.types.nullOr lib.types.str;
179 default = null;
180 description = "URL for Healthchecks.io monitoring endpoint (optional).";
181 example = "https://hc-ping.com/your-uuid";
182 };
183
184 uptimeKuma = lib.mkOption {
185 type = lib.types.nullOr lib.types.str;
186 default = null;
187 description = "URL for Uptime Kuma push monitor endpoint (optional).";
188 example = "https://status.example.com/api/push/tag?status=up&msg=OK&ping=";
189 };
190
191 shoutrrr = lib.mkOption {
192 type = lib.types.nullOr (lib.types.listOf lib.types.str);
193 default = null;
194 description = "List of Shoutrrr notification service URLs (optional).";
195 example = [
196 "discord://token@id"
197 "gotify://host/token"
198 ];
199 };
200
201 user = lib.mkOption {
202 type = lib.types.str;
203 default = "cloudflare-ddns";
204 description = "User account under which the service runs.";
205 };
206
207 group = lib.mkOption {
208 type = lib.types.str;
209 default = "cloudflare-ddns";
210 description = "Group under which the service runs.";
211 };
212 };
213
214 config = lib.mkIf cfg.enable {
215 assertions = [
216 {
217 assertion = cfg.ttl == 1 || (cfg.ttl >= 30 && cfg.ttl <= 86400);
218 message = "services.cloudflare-ddns.ttl must be 1 or between 30 and 86400";
219 }
220 {
221 assertion = cfg.updateCron == "@once" -> !cfg.deleteOnStop;
222 message = "services.cloudflare-ddns.deleteOnStop cannot be true when updateCron is \"@once\"";
223 }
224 {
225 assertion =
226 cfg.domains != [ ] || cfg.ip4Domains != null || cfg.ip6Domains != null || cfg.wafLists != [ ];
227 message = "services.cloudflare-ddns requires at least one domain (domains, ip4Domains, ip6Domains) or WAF list (wafLists) to be specified";
228 }
229 {
230 assertion = cfg.provider.ipv4 != "none" || cfg.provider.ipv6 != "none";
231 message = "services.cloudflare-ddns requires at least one provider (ipv4 or ipv6) to be enabled (not 'none')";
232 }
233 ];
234
235 users.users.${cfg.user} = {
236 description = "Cloudflare DDNS service user";
237 isSystemUser = true;
238 group = cfg.group;
239 home = "/var/lib/${cfg.user}";
240 };
241
242 users.groups.${cfg.group} = { };
243
244 systemd.tmpfiles.settings."cloudflare-ddns" = {
245 "/var/lib/${cfg.user}".d = {
246 mode = "0750";
247 user = cfg.user;
248 group = cfg.group;
249 };
250 };
251
252 systemd.services.cloudflare-ddns = {
253 description = "Cloudflare Dynamic DNS Client Service (favonia)";
254 wantedBy = [ "multi-user.target" ];
255 after = [ "network-online.target" ];
256 wants = [ "network-online.target" ];
257
258 serviceConfig = {
259 User = cfg.user;
260 Group = cfg.group;
261
262 WorkingDirectory = "/var/lib/${cfg.user}";
263
264 EnvironmentFile = cfg.credentialsFile;
265
266 Environment =
267 let
268 toEnv = name: value: "${name}=\"${toString value}\"";
269 toEnvList = name: value: "${name}=\"${formatList value}\"";
270 toEnvDuration = name: value: "${name}=\"${formatDuration value}\"";
271 toEnvBool = name: value: "${name}=\"${boolToString value}\"";
272 toEnvMaybe =
273 pred: name: value:
274 lib.optionalString pred (toEnv name value);
275 toEnvMaybeList =
276 pred: name: value:
277 lib.optionalString pred (toEnvList name value);
278 in
279 lib.filter (envVar: envVar != "") [
280 (toEnvList "DOMAINS" cfg.domains)
281 (toEnvMaybeList (cfg.ip4Domains != null) "IP4_DOMAINS" cfg.ip4Domains)
282 (toEnvMaybeList (cfg.ip6Domains != null) "IP6_DOMAINS" cfg.ip6Domains)
283
284 (toEnv "IP4_PROVIDER" cfg.provider.ipv4)
285 (toEnv "IP6_PROVIDER" cfg.provider.ipv6)
286
287 (toEnvMaybeList (cfg.wafLists != [ ]) "WAF_LISTS" cfg.wafLists)
288 (toEnvMaybe (cfg.wafListDescription != "") "WAF_LIST_DESCRIPTION" cfg.wafListDescription)
289
290 (toEnv "UPDATE_CRON" cfg.updateCron)
291 (toEnvBool "UPDATE_ON_START" cfg.updateOnStart)
292 (toEnvBool "DELETE_ON_STOP" cfg.deleteOnStop)
293 (toEnv "CACHE_EXPIRATION" cfg.cacheExpiration)
294
295 (toEnv "TTL" cfg.ttl)
296 (toEnv "PROXIED" cfg.proxied)
297 (toEnvMaybe (cfg.recordComment != "") "RECORD_COMMENT" cfg.recordComment)
298
299 (toEnv "DETECTION_TIMEOUT" cfg.detectionTimeout)
300 (toEnv "UPDATE_TIMEOUT" cfg.updateTimeout)
301
302 (toEnvMaybe (cfg.healthchecks != null) "HEALTHCHECKS" cfg.healthchecks)
303 (toEnvMaybe (cfg.uptimeKuma != null) "UPTIMEKUMA" cfg.uptimeKuma)
304 (toEnvMaybeList (cfg.shoutrrr != null) "SHOUTRRR" (lib.concatStringsSep "\n" cfg.shoutrrr))
305 ];
306
307 ExecStart = lib.getExe cfg.package;
308
309 Restart = "on-failure";
310 RestartSec = "30s";
311
312 ProtectSystem = "strict";
313 ProtectHome = true;
314 PrivateTmp = true;
315 PrivateDevices = true;
316 ProtectKernelTunables = true;
317 ProtectKernelModules = true;
318 ProtectControlGroups = true;
319 NoNewPrivileges = true;
320 RestrictAddressFamilies = [
321 "AF_INET"
322 "AF_INET6"
323 ];
324 };
325 };
326 };
327}