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