1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8let
9 cfg = config.services.wstunnel;
10
11 argsFormat = {
12 type =
13 let
14 inherit (lib.types)
15 attrsOf
16 listOf
17 oneOf
18 bool
19 int
20 str
21 ;
22 in
23 attrsOf (oneOf [
24 bool
25 int
26 str
27 (listOf str)
28 ]);
29 generate = lib.cli.toGNUCommandLineShell { };
30 };
31
32 hostPortToString = { host, port, ... }: "${host}:${toString port}";
33
34 commonOptions = {
35 enable = lib.mkEnableOption "this `wstunnel` instance" // {
36 default = true;
37 };
38
39 package = lib.mkPackageOption pkgs "wstunnel" { };
40
41 autoStart = lib.mkEnableOption "starting this wstunnel instance automatically" // {
42 default = true;
43 };
44
45 environmentFile = lib.mkOption {
46 description = ''
47 Environment file to be passed to the systemd service.
48 Useful for passing secrets to the service to prevent them from being
49 world-readable in the Nix store.
50 Note however that the secrets are passed to `wstunnel` through
51 the command line, which makes them locally readable for all users of
52 the system at runtime.
53 '';
54 type = lib.types.nullOr lib.types.path;
55 default = null;
56 example = "/var/lib/secrets/wstunnelSecrets";
57 };
58 };
59
60 serverSubmodule =
61 let
62 outerConfig = config;
63 in
64 { config, ... }:
65 let
66 certConfig = outerConfig.security.acme.certs.${config.useACMEHost};
67 in
68 {
69 imports = [
70 ../../misc/assertions.nix
71
72 (lib.mkRenamedOptionModule
73 [
74 "enableHTTPS"
75 ]
76 [
77 "listen"
78 "enableHTTPS"
79 ]
80 )
81 ]
82 ++
83 lib.map
84 (
85 option:
86 lib.mkRemovedOptionModule [ option ] ''
87 The wstunnel module now uses RFC-42-style settings, please modify your config accordingly
88 ''
89 )
90 [
91 "extraArgs"
92 "websocketPingInterval"
93 "loggingLevel"
94
95 "restrictTo"
96 "tlsCertificate"
97 "tlsKey"
98 ];
99
100 options = commonOptions // {
101 listen = lib.mkOption {
102 description = ''
103 Address and port to listen on.
104 Setting the port to a value below 1024 will also give the process
105 the required `CAP_NET_BIND_SERVICE` capability.
106 '';
107 type = lib.types.submodule {
108 options = {
109 host = lib.mkOption {
110 description = "The hostname.";
111 type = lib.types.str;
112 };
113 port = lib.mkOption {
114 description = "The port.";
115 type = lib.types.port;
116 };
117 enableHTTPS = lib.mkOption {
118 description = "Use HTTPS for the tunnel server.";
119 type = lib.types.bool;
120 default = true;
121 };
122 };
123 };
124 default =
125 { config, ... }:
126 {
127 host = "0.0.0.0";
128 port = if config.enableHTTPS then 443 else 80;
129 };
130 defaultText = lib.literalExpression ''
131 { config, ... }:
132 {
133 host = "0.0.0.0";
134 port = if config.enableHTTPS then 443 else 80;
135 }
136 '';
137 };
138
139 useACMEHost = lib.mkOption {
140 description = ''
141 Use a certificate generated by the NixOS ACME module for the given host.
142 Note that this will not generate a new certificate - you will need to do so with `security.acme.certs`.
143 '';
144 type = lib.types.nullOr lib.types.str;
145 default = null;
146 example = "example.com";
147 };
148
149 settings = lib.mkOption {
150 type = lib.types.submodule {
151 freeformType = argsFormat.type;
152
153 options = {
154 restrict-to = lib.mkOption {
155 type = lib.types.listOf (
156 lib.types.submodule {
157 options = {
158 host = lib.mkOption {
159 description = "The hostname.";
160 type = lib.types.str;
161 };
162 port = lib.mkOption {
163 description = "The port.";
164 type = lib.types.port;
165 };
166 };
167 }
168 );
169 default = [ ];
170 example = [
171 {
172 host = "127.0.0.1";
173 port = 51820;
174 }
175 ];
176 description = ''
177 Restrictions on the connections that the server will accept.
178 For more flexibility, and the possibility to also allow reverse tunnels,
179 look into the `restrict-config` option that takes a path to a yaml file.
180 '';
181 };
182 };
183 };
184 default = { };
185 description = ''
186 Command line arguments to pass to `wstunnel`.
187 Attributes of the form `argName = true;` will be translated to `--argName`,
188 and `argName = \"value\"` to `--argName value`.
189 '';
190 example = {
191 "someNewOption" = true;
192 "someNewOptionWithValue" = "someValue";
193 };
194 };
195 };
196
197 config = {
198 settings = lib.mkIf (config.useACMEHost != null) {
199 tls-certificate = "${certConfig.directory}/fullchain.pem";
200 tls-private-key = "${certConfig.directory}/key.pem";
201 };
202 };
203 };
204
205 clientSubmodule =
206 { config, ... }:
207 {
208 imports = [
209 ../../misc/assertions.nix
210 ]
211 ++
212 lib.map
213 (
214 option:
215 lib.mkRemovedOptionModule [ option ] ''
216 The wstunnel module now uses RFC-42-style settings, please modify your config accordingly
217 ''
218 )
219 [
220 "extraArgs"
221 "websocketPingInterval"
222 "loggingLevel"
223
224 "localToRemote"
225 "remoteToLocal"
226 "httpProxy"
227 "soMark"
228 "upgradePathPrefix"
229 "tlsSNI"
230 "tlsVerifyCertificate"
231 "upgradeCredentials"
232 "customHeaders"
233 ];
234
235 options = commonOptions // {
236 connectTo = lib.mkOption {
237 description = "Server address and port to connect to.";
238 type = lib.types.str;
239 example = "https://wstunnel.server.com:8443";
240 };
241
242 addNetBind = lib.mkEnableOption "Whether add CAP_NET_BIND_SERVICE to the tunnel service, this should be enabled if you want to bind port < 1024";
243
244 settings = lib.mkOption {
245 type = lib.types.submodule {
246 freeformType = argsFormat.type;
247
248 options = {
249 http-headers = lib.mkOption {
250 type = lib.types.coercedTo (lib.types.attrsOf lib.types.str) (lib.mapAttrsToList (
251 n: v: "${n}:${v}"
252 )) (lib.types.listOf lib.types.str);
253 default = { };
254 example = {
255 "X-Some-Header" = "some-value";
256 };
257 description = ''
258 Custom headers to send in the upgrade request
259 '';
260 };
261 };
262 };
263 default = { };
264 description = ''
265 Command line arguments to pass to `wstunnel`.
266 Attributes of the form `argName = true;` will be translated to `--argName`,
267 and `argName = \"value\"` to `--argName value`.
268 '';
269 example = {
270 "someNewOption" = true;
271 "someNewOptionWithValue" = "someValue";
272 };
273 };
274 };
275 };
276
277 generateServerUnit = name: serverCfg: {
278 name = "wstunnel-server-${name}";
279 value =
280 let
281 certConfig = config.security.acme.certs.${serverCfg.useACMEHost};
282 in
283 {
284 description = "wstunnel server - ${name}";
285 requires = [
286 "network.target"
287 "network-online.target"
288 ];
289 after = [
290 "network.target"
291 "network-online.target"
292 ];
293 wantedBy = lib.optional serverCfg.autoStart "multi-user.target";
294
295 serviceConfig = {
296 Type = "exec";
297 EnvironmentFile = lib.optional (serverCfg.environmentFile != null) serverCfg.environmentFile;
298 DynamicUser = true;
299 SupplementaryGroups = lib.optional (serverCfg.useACMEHost != null) certConfig.group;
300 PrivateTmp = true;
301 AmbientCapabilities = lib.optionals (serverCfg.listen.port < 1024) [ "CAP_NET_BIND_SERVICE" ];
302 NoNewPrivileges = true;
303 RestrictNamespaces = [
304 "uts"
305 "ipc"
306 "pid"
307 "user"
308 "cgroup"
309 ];
310 ProtectSystem = "strict";
311 ProtectHome = true;
312 ProtectKernelTunables = true;
313 ProtectKernelModules = true;
314 ProtectControlGroups = true;
315 PrivateDevices = true;
316 RestrictSUIDSGID = true;
317
318 Restart = "on-failure";
319 RestartSec = 2;
320 RestartSteps = 20;
321 RestartMaxDelaySec = "5min";
322
323 ExecStart =
324 let
325 convertedSettings = serverCfg.settings // {
326 restrict-to = lib.map hostPortToString serverCfg.settings.restrict-to;
327 };
328 in
329 ''
330 ${lib.getExe serverCfg.package} \
331 server \
332 ${argsFormat.generate convertedSettings} \
333 ${lib.escapeShellArg "${
334 if serverCfg.listen.enableHTTPS then "wss" else "ws"
335 }://${hostPortToString serverCfg.listen}"}
336 '';
337 };
338 };
339 };
340
341 generateClientUnit = name: clientCfg: {
342 name = "wstunnel-client-${name}";
343 value = {
344 description = "wstunnel client - ${name}";
345 requires = [
346 "network.target"
347 "network-online.target"
348 ];
349 after = [
350 "network.target"
351 "network-online.target"
352 ];
353 wantedBy = lib.optional clientCfg.autoStart "multi-user.target";
354
355 serviceConfig = {
356 Type = "exec";
357 EnvironmentFile = lib.optional (clientCfg.environmentFile != null) clientCfg.environmentFile;
358 DynamicUser = true;
359 PrivateTmp = true;
360 AmbientCapabilities =
361 (lib.optionals clientCfg.addNetBind [ "CAP_NET_BIND_SERVICE" ])
362 ++ (lib.optionals ((clientCfg.settings.socket-so-mark or null) != null) [ "CAP_NET_ADMIN" ]);
363 NoNewPrivileges = true;
364 RestrictNamespaces = [
365 "uts"
366 "ipc"
367 "pid"
368 "user"
369 "cgroup"
370 ];
371 ProtectSystem = "strict";
372 ProtectHome = true;
373 ProtectKernelTunables = true;
374 ProtectKernelModules = true;
375 ProtectControlGroups = true;
376 PrivateDevices = true;
377 RestrictSUIDSGID = true;
378
379 Restart = "on-failure";
380 RestartSec = 2;
381 RestartSteps = 20;
382 RestartMaxDelaySec = "5min";
383
384 ExecStart = ''
385 ${lib.getExe clientCfg.package} \
386 client \
387 ${argsFormat.generate clientCfg.settings} \
388 ${lib.escapeShellArg clientCfg.connectTo}
389 '';
390 };
391 };
392 };
393in
394{
395 options.services.wstunnel = {
396 enable = lib.mkEnableOption "wstunnel";
397
398 servers = lib.mkOption {
399 description = "`wstunnel` servers to set up.";
400 type = lib.types.attrsOf (lib.types.submodule serverSubmodule);
401 default = { };
402 example = {
403 "wg-tunnel" = {
404 listen = {
405 host = "0.0.0.0";
406 port = 8080;
407 enableHTTPS = true;
408 };
409 settings = {
410 tls-certificate = "/var/lib/secrets/fullchain.pem";
411 tls-private-key = "/var/lib/secrets/key.pem";
412 restrict-to = [
413 {
414 host = "127.0.0.1";
415 port = 51820;
416 }
417 ];
418 };
419 };
420 };
421 };
422
423 clients = lib.mkOption {
424 description = "`wstunnel` clients to set up.";
425 type = lib.types.attrsOf (lib.types.submodule clientSubmodule);
426 default = { };
427 example = {
428 "wg-tunnel" = {
429 connectTo = "wss://wstunnel.server.com:8443";
430 localToRemote = [
431 "tcp://1212:google.com:443"
432 "tcp://2:n.lan:4?proxy_protocol"
433 ];
434 remoteToLocal = [
435 "socks5://[::1]:1212"
436 "unix://wstunnel.sock:g.com:443"
437 ];
438 };
439 };
440 };
441 };
442
443 config = lib.mkIf cfg.enable {
444 systemd.services =
445 (lib.mapAttrs' generateServerUnit (lib.filterAttrs (_: v: v.enable) cfg.servers))
446 // (lib.mapAttrs' generateClientUnit (lib.filterAttrs (_: v: v.enable) cfg.clients));
447
448 assertions =
449 (lib.mapAttrsToList (name: serverCfg: {
450 assertion =
451 serverCfg.listen.enableHTTPS
452 ->
453 (serverCfg.useACMEHost != null)
454 || (
455 (serverCfg.settings.tls-certificate or null) != null
456 && (serverCfg.settings.tls-private-key or null) != null
457 );
458 message = ''
459 If services.wstunnel.servers."${name}".listen.enableHTTPS is set to true, either services.wstunnel.servers."${name}".useACMEHost or both services.wstunnel.servers."${name}".settings.tls-private-key and services.wstunnel.servers."${name}".settings.tls-certificate need to be set.
460 '';
461 }) cfg.servers)
462 ++ (lib.foldlAttrs (
463 assertions: _: server:
464 assertions ++ server.assertions
465 ) [ ] cfg.servers)
466
467 ++ (lib.mapAttrsToList (
468 name: clientCfg:
469 let
470 isListAttrDefined = settings: attr: (settings.${attr} or [ ]) != [ ];
471 in
472 {
473 assertion =
474 isListAttrDefined clientCfg.settings "local-to-remote"
475 || isListAttrDefined clientCfg.settings "remote-to-local";
476 message = ''
477 Either one of services.wstunnel.clients."${name}".settings.local-to-remote or services.wstunnel.clients."${name}".settings.remote-to-local must be set.
478 '';
479 }
480 ) cfg.clients)
481 ++ (lib.foldlAttrs (
482 assertions: _: client:
483 assertions ++ client.assertions
484 ) [ ] cfg.clients);
485
486 warnings =
487 (lib.foldlAttrs (
488 warnings: _: server:
489 warnings ++ server.warnings
490 ) [ ] cfg.servers)
491 ++ (lib.foldlAttrs (
492 warnings: _: client:
493 warnings ++ client.warnings
494 ) [ ] cfg.clients);
495 };
496
497 meta.maintainers = with lib.maintainers; [
498 pentane
499 raylas
500 rvdp
501 neverbehave
502 ];
503}