1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 inherit (lib)
9 concatLists
10 filterAttrs
11 mapAttrs'
12 mapAttrsToList
13 mkEnableOption
14 mkIf
15 mkOption
16 mkOverride
17 mkPackageOption
18 nameValuePair
19 recursiveUpdate
20 types
21 ;
22
23 fedimintdOpts =
24 {
25 config,
26 lib,
27 name,
28 ...
29 }:
30 {
31 options = {
32 enable = mkEnableOption "fedimintd";
33
34 package = mkPackageOption pkgs "fedimint" { };
35
36 environment = mkOption {
37 type = types.attrsOf types.str;
38 description = "Extra Environment variables to pass to the fedimintd.";
39 default = {
40 RUST_BACKTRACE = "1";
41 };
42 example = {
43 RUST_LOG = "info,fm=debug";
44 RUST_BACKTRACE = "1";
45 };
46 };
47
48 p2p = {
49 openFirewall = mkOption {
50 type = types.bool;
51 default = true;
52 description = "Opens port in firewall for fedimintd's p2p port (both TCP and UDP)";
53 };
54 port = mkOption {
55 type = types.port;
56 default = 8173;
57 description = "Port to bind on for p2p connections from peers (both TCP and UDP)";
58 };
59 bind = mkOption {
60 type = types.str;
61 default = "0.0.0.0";
62 description = "Address to bind on for p2p connections from peers (both TCP and UDP)";
63 };
64 url = mkOption {
65 type = types.nullOr types.str;
66 example = "fedimint://p2p.myfedimint.com:8173";
67 description = ''
68 Public address for p2p connections from peers (if TCP is used)
69 '';
70 };
71 };
72 api_ws = {
73 openFirewall = mkOption {
74 type = types.bool;
75 default = false;
76 description = "Opens TCP port in firewall for fedimintd's Websocket API";
77 };
78 port = mkOption {
79 type = types.port;
80 default = 8174;
81 description = "TCP Port to bind on for API connections relayed by the reverse proxy/tls terminator.";
82 };
83 bind = mkOption {
84 type = types.str;
85 default = "127.0.0.1";
86 description = "Address to bind on for API connections relied by the reverse proxy/tls terminator.";
87 };
88 url = mkOption {
89 type = types.nullOr types.str;
90 description = ''
91 Public URL of the API address of the reverse proxy/tls terminator. Usually starting with `wss://`.
92 '';
93 };
94 };
95 api_iroh = {
96 openFirewall = mkOption {
97 type = types.bool;
98 default = true;
99 description = "Opens UDP port in firewall for fedimintd's API Iroh endpoint";
100 };
101 port = mkOption {
102 type = types.port;
103 default = 8174;
104 description = "UDP Port to bind Iroh endpoint for API connections";
105 };
106 bind = mkOption {
107 type = types.str;
108 default = "0.0.0.0";
109 description = "Address to bind on for Iroh endpoint for API connections";
110 };
111 };
112 ui = {
113 openFirewall = mkOption {
114 type = types.bool;
115 default = false;
116 description = "Opens TCP port in firewall for built-in UI";
117 };
118 port = mkOption {
119 type = types.port;
120 default = 8175;
121 description = "TCP Port to bind on for UI connections";
122 };
123 bind = mkOption {
124 type = types.str;
125 default = "127.0.0.1";
126 description = "Address to bind on for UI connections";
127 };
128 };
129 bitcoin = {
130 network = mkOption {
131 type = types.str;
132 default = "signet";
133 example = "bitcoin";
134 description = "Bitcoin network to participate in.";
135 };
136 rpc = {
137 url = mkOption {
138 type = types.str;
139 default = "http://127.0.0.1:38332";
140 example = "signet";
141 description = "Bitcoin node (bitcoind/electrum/esplora) address to connect to";
142 };
143
144 kind = mkOption {
145 type = types.str;
146 default = "bitcoind";
147 example = "electrum";
148 description = "Kind of a bitcoin node.";
149 };
150
151 secretFile = mkOption {
152 type = types.nullOr types.path;
153 default = null;
154 description = ''
155 If set the URL specified in `bitcoin.rpc.url` will get the content of this file added
156 as an URL password, so `http://user@example.com` will turn into `http://user:SOMESECRET@example.com`.
157
158 Example:
159
160 `/etc/nix-bitcoin-secrets/bitcoin-rpcpassword-public` (for nix-bitcoin default)
161 '';
162 };
163 };
164 };
165
166 consensus.finalityDelay = mkOption {
167 type = types.ints.unsigned;
168 default = 10;
169 description = "Consensus peg-in finality delay.";
170 };
171
172 dataDir = mkOption {
173 type = types.path;
174 default = "/var/lib/fedimintd-${name}/";
175 readOnly = true;
176 description = ''
177 Path to the data dir fedimintd will use to store its data.
178 Note that due to using the DynamicUser feature of systemd, this value should not be changed
179 and is set to be read only.
180 '';
181 };
182
183 nginx = {
184 enable = mkOption {
185 type = types.bool;
186 default = false;
187 description = ''
188 Whether to configure nginx for fedimintd
189 '';
190 };
191 fqdn = mkOption {
192 type = types.str;
193 example = "api.myfedimint.com";
194 description = "Public domain of the API address of the reverse proxy/tls terminator.";
195 };
196 path_ui = mkOption {
197 type = types.str;
198 example = "/";
199 default = "/";
200 description = "Path to host the built-in UI on and forward to the daemon's api port";
201 };
202 path_ws = mkOption {
203 type = types.str;
204 example = "/";
205 default = "/ws/";
206 description = "Path to host the API on and forward to the daemon's api port";
207 };
208 config = mkOption {
209 type = types.submodule (
210 recursiveUpdate (import ../web-servers/nginx/vhost-options.nix {
211 inherit config lib;
212 }) { }
213 );
214 default = { };
215 description = "Overrides to the nginx vhost section for api";
216 };
217 };
218 };
219 };
220in
221{
222 options = {
223 services.fedimintd = mkOption {
224 type = types.attrsOf (types.submodule fedimintdOpts);
225 default = { };
226 description = "Specification of one or more fedimintd instances.";
227 };
228 };
229
230 config =
231 let
232 eachFedimintd = filterAttrs (fedimintdName: cfg: cfg.enable) config.services.fedimintd;
233 eachFedimintdNginx = filterAttrs (fedimintdName: cfg: cfg.nginx.enable) eachFedimintd;
234 in
235 mkIf (eachFedimintd != { }) {
236
237 networking.firewall.allowedTCPPorts = concatLists (
238 mapAttrsToList (
239 fedimintdName: cfg:
240 (
241 lib.optional cfg.api_ws.openFirewall cfg.api_ws.port
242 ++ lib.optional cfg.p2p.openFirewall cfg.p2p.port
243 ++ lib.optional cfg.ui.openFirewall cfg.ui.port
244 )
245 ) eachFedimintd
246 );
247
248 networking.firewall.allowedUDPPorts = concatLists (
249 mapAttrsToList (
250 fedimintdName: cfg:
251 (
252 lib.optional cfg.api_iroh.openFirewall cfg.api_iroh.port
253 ++ lib.optional cfg.p2p.openFirewall cfg.p2p.port
254 )
255 ) eachFedimintd
256 );
257
258 systemd.services = mapAttrs' (
259 fedimintdName: cfg:
260 (nameValuePair "fedimintd-${fedimintdName}" (
261 let
262 startScript = pkgs.writeShellScriptBin "fedimintd" (
263 (
264 if cfg.bitcoin.rpc.secretFile != null then
265 ''
266 >&2 echo "Setting FM_FORCE_BITCOIN_RPC_URL using password from ${cfg.bitcoin.rpc.secretFile}"
267 secret=$(${pkgs.coreutils}/bin/head -n 1 "${cfg.bitcoin.rpc.secretFile}" || exit 1)
268 export FM_FORCE_BITCOIN_RPC_URL=$(echo "$FM_BITCOIN_RPC_URL" | sed "s|^\(\w\+://[^@]\+\)\(@.*\)|\1:''${secret}\2|")
269 ''
270 else
271 ""
272 )
273 + ''
274 exec ${cfg.package}/bin/fedimintd
275 ''
276 );
277 in
278 {
279 description = "Fedimint Server";
280 documentation = [ "https://github.com/fedimint/fedimint/" ];
281 wantedBy = [ "multi-user.target" ];
282 environment = lib.mkMerge [
283 {
284 FM_BIND_P2P = "${cfg.p2p.bind}:${toString cfg.p2p.port}";
285 FM_BIND_API_WS = "${cfg.api_ws.bind}:${toString cfg.api_ws.port}";
286 FM_BIND_API_IROH = "${cfg.api_iroh.bind}:${toString cfg.api_iroh.port}";
287 FM_BIND_UI = "${cfg.ui.bind}:${toString cfg.ui.port}";
288 FM_DATA_DIR = cfg.dataDir;
289 FM_BITCOIN_NETWORK = cfg.bitcoin.network;
290 FM_BITCOIN_RPC_URL = cfg.bitcoin.rpc.url;
291 FM_BITCOIN_RPC_KIND = cfg.bitcoin.rpc.kind;
292 }
293
294 (lib.optionalAttrs (cfg.p2p.url != null) {
295 FM_P2P_URL = cfg.p2p.url;
296 })
297
298 (lib.optionalAttrs (cfg.api_ws.url != null) {
299 FM_API_URL = cfg.api_ws.url;
300 })
301
302 cfg.environment
303 ];
304 serviceConfig = {
305 DynamicUser = true;
306
307 StateDirectory = "fedimintd-${fedimintdName}";
308 StateDirectoryMode = "0700";
309 ExecStart = "${startScript}/bin/fedimintd";
310
311 Restart = "always";
312 RestartSec = 10;
313 UMask = "007";
314 LimitNOFILE = "100000";
315
316 LockPersonality = true;
317 MemoryDenyWriteExecute = true;
318 NoNewPrivileges = true;
319 PrivateDevices = true;
320 PrivateMounts = true;
321 PrivateTmp = true;
322 ProtectClock = true;
323 ProtectControlGroups = true;
324 ProtectHostname = true;
325 ProtectKernelLogs = true;
326 ProtectKernelModules = true;
327 ProtectKernelTunables = true;
328 ProtectSystem = "full";
329 RestrictAddressFamilies = [
330 "AF_INET"
331 "AF_INET6"
332 "AF_NETLINK"
333 ];
334 RestrictNamespaces = true;
335 RestrictRealtime = true;
336 SocketBindAllow = "udp:${builtins.toString cfg.api_iroh.port}";
337 SystemCallArchitectures = "native";
338 SystemCallFilter = [
339 "@system-service"
340 "~@privileged"
341 ];
342 };
343 unitConfig = {
344 StartLimitBurst = 5;
345 };
346 }
347 ))
348 ) eachFedimintd;
349
350 services.nginx.virtualHosts = mapAttrs' (
351 fedimintdName: cfg:
352 (nameValuePair cfg.nginx.fqdn (
353 lib.mkMerge [
354 cfg.nginx.config
355
356 {
357 # Note: we want by default to enable OpenSSL, but it seems anything 100 and above is
358 # overridden by default value from vhost-options.nix
359 enableACME = mkOverride 99 true;
360 forceSSL = mkOverride 99 true;
361 locations.${cfg.nginx.path_ws} = {
362 proxyPass = "http://127.0.0.1:${builtins.toString cfg.api_ws.port}/";
363 proxyWebsockets = true;
364 extraConfig = ''
365 proxy_pass_header Authorization;
366 '';
367 };
368 locations.${cfg.nginx.path_ui} = {
369 proxyPass = "http://127.0.0.1:${builtins.toString cfg.ui.port}/";
370 extraConfig = ''
371 proxy_pass_header Authorization;
372 '';
373 };
374 }
375 ]
376 ))
377 ) eachFedimintdNginx;
378 };
379
380 meta.maintainers = with lib.maintainers; [ dpc ];
381}