1{ config, lib, options, pkgs, utils, ... }:
2with lib;
3let
4 cfg = config.services.wstunnel;
5 attrsToArgs = attrs: utils.escapeSystemdExecArgs (
6 mapAttrsToList
7 (name: value: if value == true then "--${name}" else "--${name}=${value}")
8 attrs
9 );
10 hostPortSubmodule = {
11 options = {
12 host = mkOption {
13 description = mdDoc "The hostname.";
14 type = types.str;
15 };
16 port = mkOption {
17 description = mdDoc "The port.";
18 type = types.port;
19 };
20 };
21 };
22 localRemoteSubmodule = {
23 options = {
24 local = mkOption {
25 description = mdDoc "Local address and port to listen on.";
26 type = types.submodule hostPortSubmodule;
27 example = {
28 host = "127.0.0.1";
29 port = 51820;
30 };
31 };
32 remote = mkOption {
33 description = mdDoc "Address and port on remote to forward traffic to.";
34 type = types.submodule hostPortSubmodule;
35 example = {
36 host = "127.0.0.1";
37 port = 51820;
38 };
39 };
40 };
41 };
42 hostPortToString = { host, port }: "${host}:${builtins.toString port}";
43 localRemoteToString = { local, remote }: utils.escapeSystemdExecArg "${hostPortToString local}:${hostPortToString remote}";
44 commonOptions = {
45 enable = mkOption {
46 description = mdDoc "Whether to enable this `wstunnel` instance.";
47 type = types.bool;
48 default = true;
49 };
50
51 package = mkPackageOptionMD pkgs "wstunnel" {};
52
53 autoStart = mkOption {
54 description = mdDoc "Whether this tunnel server should be started automatically.";
55 type = types.bool;
56 default = true;
57 };
58
59 extraArgs = mkOption {
60 description = mdDoc "Extra command line arguments to pass to `wstunnel`. Attributes of the form `argName = true;` will be translated to `--argName`, and `argName = \"value\"` to `--argName=value`.";
61 type = with types; attrsOf (either str bool);
62 default = {};
63 example = {
64 "someNewOption" = true;
65 "someNewOptionWithValue" = "someValue";
66 };
67 };
68
69 verboseLogging = mkOption {
70 description = mdDoc "Enable verbose logging.";
71 type = types.bool;
72 default = false;
73 };
74
75 environmentFile = mkOption {
76 description = mdDoc "Environment file to be passed to the systemd service. Useful for passing secrets to the service to prevent them from being world-readable in the Nix store. Note however that the secrets are passed to `wstunnel` through the command line, which makes them locally readable for all users of the system at runtime.";
77 type = types.nullOr types.path;
78 default = null;
79 example = "/var/lib/secrets/wstunnelSecrets";
80 };
81 };
82
83 serverSubmodule = { config, ...}: {
84 options = commonOptions // {
85 listen = mkOption {
86 description = mdDoc "Address and port to listen on. Setting the port to a value below 1024 will also give the process the required `CAP_NET_BIND_SERVICE` capability.";
87 type = types.submodule hostPortSubmodule;
88 default = {
89 address = "0.0.0.0";
90 port = if config.enableHTTPS then 443 else 80;
91 };
92 defaultText = literalExpression ''
93 {
94 address = "0.0.0.0";
95 port = if enableHTTPS then 443 else 80;
96 }
97 '';
98 };
99
100 restrictTo = mkOption {
101 description = mdDoc "Accepted traffic will be forwarded only to this service. Set to `null` to allow forwarding to arbitrary addresses.";
102 type = types.nullOr (types.submodule hostPortSubmodule);
103 example = {
104 host = "127.0.0.1";
105 port = 51820;
106 };
107 };
108
109 enableHTTPS = mkOption {
110 description = mdDoc "Use HTTPS for the tunnel server.";
111 type = types.bool;
112 default = true;
113 };
114
115 tlsCertificate = mkOption {
116 description = mdDoc "TLS certificate to use instead of the hardcoded one in case of HTTPS connections. Use together with `tlsKey`.";
117 type = types.nullOr types.path;
118 default = null;
119 example = "/var/lib/secrets/cert.pem";
120 };
121
122 tlsKey = mkOption {
123 description = mdDoc "TLS key to use instead of the hardcoded on in case of HTTPS connections. Use together with `tlsCertificate`.";
124 type = types.nullOr types.path;
125 default = null;
126 example = "/var/lib/secrets/key.pem";
127 };
128
129 useACMEHost = mkOption {
130 description = mdDoc "Use a certificate generated by the NixOS ACME module for the given host. Note that this will not generate a new certificate - you will need to do so with `security.acme.certs`.";
131 type = types.nullOr types.str;
132 default = null;
133 example = "example.com";
134 };
135 };
136 };
137 clientSubmodule = { config, ... }: {
138 options = commonOptions // {
139 connectTo = mkOption {
140 description = mdDoc "Server address and port to connect to.";
141 type = types.submodule hostPortSubmodule;
142 example = {
143 host = "example.com";
144 };
145 };
146
147 enableHTTPS = mkOption {
148 description = mdDoc "Enable HTTPS when connecting to the server.";
149 type = types.bool;
150 default = true;
151 };
152
153 localToRemote = mkOption {
154 description = mdDoc "Local hosts and ports to listen on, plus the hosts and ports on remote to forward traffic to. Setting a local port to a value less than 1024 will additionally give the process the required CAP_NET_BIND_SERVICE capability.";
155 type = types.listOf (types.submodule localRemoteSubmodule);
156 default = [];
157 example = [ {
158 local = {
159 host = "127.0.0.1";
160 port = 8080;
161 };
162 remote = {
163 host = "127.0.0.1";
164 port = 8080;
165 };
166 } ];
167 };
168
169 dynamicToRemote = mkOption {
170 description = mdDoc "Host and port for the SOCKS5 proxy to dynamically forward traffic to. Leave this at `null` to disable the SOCKS5 proxy. Setting the port to a value less than 1024 will additionally give the service the required CAP_NET_BIND_SERVICE capability.";
171 type = types.nullOr (types.submodule hostPortSubmodule);
172 default = null;
173 example = {
174 host = "127.0.0.1";
175 port = 1080;
176 };
177 };
178
179 udp = mkOption {
180 description = mdDoc "Whether to forward UDP instead of TCP traffic.";
181 type = types.bool;
182 default = false;
183 };
184
185 udpTimeout = mkOption {
186 description = mdDoc "When using UDP forwarding, timeout in seconds after which the tunnel connection is closed. `-1` means no timeout.";
187 type = types.int;
188 default = 30;
189 };
190
191 httpProxy = mkOption {
192 description = mdDoc ''
193 Proxy to use to connect to the wstunnel server (`USER:PASS@HOST:PORT`).
194
195 ::: {.warning}
196 Passwords specified here will be world-readable in the Nix store! To pass a password to the service, point the `environmentFile` option to a file containing `PROXY_PASSWORD=<your-password-here>` and set this option to `<user>:$PROXY_PASSWORD@<host>:<port>`. Note however that this will also locally leak the passwords at runtime via e.g. /proc/<pid>/cmdline.
197
198 :::
199 '';
200 type = types.nullOr types.str;
201 default = null;
202 };
203
204 soMark = mkOption {
205 description = mdDoc "Mark network packets with the SO_MARK sockoption with the specified value. Setting this option will also enable the required `CAP_NET_ADMIN` capability for the systemd service.";
206 type = types.nullOr types.int;
207 default = null;
208 };
209
210 upgradePathPrefix = mkOption {
211 description = mdDoc "Use a specific HTTP path prefix that will show up in the upgrade request to the `wstunnel` server. Useful when running `wstunnel` behind a reverse proxy.";
212 type = types.nullOr types.str;
213 default = null;
214 example = "wstunnel";
215 };
216
217 hostHeader = mkOption {
218 description = mdDoc "Use this as the HTTP host header instead of the real hostname. Useful for circumventing hostname-based firewalls.";
219 type = types.nullOr types.str;
220 default = null;
221 };
222
223 tlsSNI = mkOption {
224 description = mdDoc "Use this as the SNI while connecting via TLS. Useful for circumventing hostname-based firewalls.";
225 type = types.nullOr types.str;
226 default = null;
227 };
228
229 tlsVerifyCertificate = mkOption {
230 description = mdDoc "Whether to verify the TLS certificate of the server. It might be useful to set this to `false` when working with the `tlsSNI` option.";
231 type = types.bool;
232 default = true;
233 };
234
235 # The original argument name `websocketPingFrequency` is a misnomer, as the frequency is the inverse of the interval.
236 websocketPingInterval = mkOption {
237 description = mdDoc "Do a heartbeat ping every N seconds to keep up the websocket connection.";
238 type = types.nullOr types.ints.unsigned;
239 default = null;
240 };
241
242 upgradeCredentials = mkOption {
243 description = mdDoc ''
244 Use these credentials to authenticate during the HTTP upgrade request (Basic authorization type, `USER:[PASS]`).
245
246 ::: {.warning}
247 Passwords specified here will be world-readable in the Nix store! To pass a password to the service, point the `environmentFile` option to a file containing `HTTP_PASSWORD=<your-password-here>` and set this option to `<user>:$HTTP_PASSWORD`. Note however that this will also locally leak the passwords at runtime via e.g. /proc/<pid>/cmdline.
248 :::
249 '';
250 type = types.nullOr types.str;
251 default = null;
252 };
253
254 customHeaders = mkOption {
255 description = mdDoc "Custom HTTP headers to send during the upgrade request.";
256 type = types.attrsOf types.str;
257 default = {};
258 example = {
259 "X-Some-Header" = "some-value";
260 };
261 };
262 };
263 };
264 generateServerUnit = name: serverCfg: {
265 name = "wstunnel-server-${name}";
266 value = {
267 description = "wstunnel server - ${name}";
268 requires = [ "network.target" "network-online.target" ];
269 after = [ "network.target" "network-online.target" ];
270 wantedBy = optional serverCfg.autoStart "multi-user.target";
271
272 serviceConfig = let
273 certConfig = config.security.acme.certs."${serverCfg.useACMEHost}";
274 in {
275 Type = "simple";
276 ExecStart = with serverCfg; let
277 resolvedTlsCertificate = if useACMEHost != null
278 then "${certConfig.directory}/fullchain.pem"
279 else tlsCertificate;
280 resolvedTlsKey = if useACMEHost != null
281 then "${certConfig.directory}/key.pem"
282 else tlsKey;
283 in ''
284 ${package}/bin/wstunnel \
285 --server \
286 ${optionalString (restrictTo != null) "--restrictTo=${utils.escapeSystemdExecArg (hostPortToString restrictTo)}"} \
287 ${optionalString (resolvedTlsCertificate != null) "--tlsCertificate=${utils.escapeSystemdExecArg resolvedTlsCertificate}"} \
288 ${optionalString (resolvedTlsKey != null) "--tlsKey=${utils.escapeSystemdExecArg resolvedTlsKey}"} \
289 ${optionalString verboseLogging "--verbose"} \
290 ${attrsToArgs extraArgs} \
291 ${utils.escapeSystemdExecArg "${if enableHTTPS then "wss" else "ws"}://${hostPortToString listen}"}
292 '';
293 EnvironmentFile = optional (serverCfg.environmentFile != null) serverCfg.environmentFile;
294 DynamicUser = true;
295 SupplementaryGroups = optional (serverCfg.useACMEHost != null) certConfig.group;
296 PrivateTmp = true;
297 AmbientCapabilities = 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 };
309 };
310 };
311 generateClientUnit = name: clientCfg: {
312 name = "wstunnel-client-${name}";
313 value = {
314 description = "wstunnel client - ${name}";
315 requires = [ "network.target" "network-online.target" ];
316 after = [ "network.target" "network-online.target" ];
317 wantedBy = optional clientCfg.autoStart "multi-user.target";
318
319 serviceConfig = {
320 Type = "simple";
321 ExecStart = with clientCfg; ''
322 ${package}/bin/wstunnel \
323 ${concatStringsSep " " (builtins.map (x: "--localToRemote=${localRemoteToString x}") localToRemote)} \
324 ${concatStringsSep " " (mapAttrsToList (n: v: "--customHeaders=\"${n}: ${v}\"") customHeaders)} \
325 ${optionalString (dynamicToRemote != null) "--dynamicToRemote=${utils.escapeSystemdExecArg (hostPortToString dynamicToRemote)}"} \
326 ${optionalString udp "--udp"} \
327 ${optionalString (httpProxy != null) "--httpProxy=${httpProxy}"} \
328 ${optionalString (soMark != null) "--soMark=${toString soMark}"} \
329 ${optionalString (upgradePathPrefix != null) "--upgradePathPrefix=${upgradePathPrefix}"} \
330 ${optionalString (hostHeader != null) "--hostHeader=${hostHeader}"} \
331 ${optionalString (tlsSNI != null) "--tlsSNI=${tlsSNI}"} \
332 ${optionalString tlsVerifyCertificate "--tlsVerifyCertificate"} \
333 ${optionalString (websocketPingInterval != null) "--websocketPingFrequency=${toString websocketPingInterval}"} \
334 ${optionalString (upgradeCredentials != null) "--upgradeCredentials=${upgradeCredentials}"} \
335 --udpTimeoutSec=${toString udpTimeout} \
336 ${optionalString verboseLogging "--verbose"} \
337 ${attrsToArgs extraArgs} \
338 ${utils.escapeSystemdExecArg "${if enableHTTPS then "wss" else "ws"}://${hostPortToString connectTo}"}
339 '';
340 EnvironmentFile = optional (clientCfg.environmentFile != null) clientCfg.environmentFile;
341 DynamicUser = true;
342 PrivateTmp = true;
343 AmbientCapabilities = (optionals (clientCfg.soMark != null) [ "CAP_NET_ADMIN" ]) ++ (optionals ((clientCfg.dynamicToRemote.port or 1024) < 1024 || (any (x: x.local.port < 1024) clientCfg.localToRemote)) [ "CAP_NET_BIND_SERVICE" ]);
344 NoNewPrivileges = true;
345 RestrictNamespaces = "uts ipc pid user cgroup";
346 ProtectSystem = "strict";
347 ProtectHome = true;
348 ProtectKernelTunables = true;
349 ProtectKernelModules = true;
350 ProtectControlGroups = true;
351 PrivateDevices = true;
352 RestrictSUIDSGID = true;
353 };
354 };
355 };
356in {
357 options.services.wstunnel = {
358 enable = mkEnableOption (mdDoc "wstunnel");
359
360 servers = mkOption {
361 description = mdDoc "`wstunnel` servers to set up.";
362 type = types.attrsOf (types.submodule serverSubmodule);
363 default = {};
364 example = {
365 "wg-tunnel" = {
366 listen.port = 8080;
367 enableHTTPS = true;
368 tlsCertificate = "/var/lib/secrets/fullchain.pem";
369 tlsKey = "/var/lib/secrets/key.pem";
370 restrictTo = {
371 host = "127.0.0.1";
372 port = 51820;
373 };
374 };
375 };
376 };
377
378 clients = mkOption {
379 description = mdDoc "`wstunnel` clients to set up.";
380 type = types.attrsOf (types.submodule clientSubmodule);
381 default = {};
382 example = {
383 "wg-tunnel" = {
384 connectTo = {
385 host = "example.com";
386 port = 8080;
387 };
388 enableHTTPS = true;
389 localToRemote = {
390 local = {
391 host = "127.0.0.1";
392 port = 51820;
393 };
394 remote = {
395 host = "127.0.0.1";
396 port = 51820;
397 };
398 };
399 udp = true;
400 };
401 };
402 };
403 };
404
405 config = mkIf cfg.enable {
406 systemd.services = (mapAttrs' generateServerUnit (filterAttrs (n: v: v.enable) cfg.servers)) // (mapAttrs' generateClientUnit (filterAttrs (n: v: v.enable) cfg.clients));
407
408 assertions = (mapAttrsToList (name: serverCfg: {
409 assertion = !(serverCfg.useACMEHost != null && (serverCfg.tlsCertificate != null || serverCfg.tlsKey != null));
410 message = ''
411 Options services.wstunnel.servers."${name}".useACMEHost and services.wstunnel.servers."${name}".{tlsCertificate, tlsKey} are mutually exclusive.
412 '';
413 }) cfg.servers) ++
414 (mapAttrsToList (name: serverCfg: {
415 assertion = !((serverCfg.tlsCertificate != null || serverCfg.tlsKey != null) && !(serverCfg.tlsCertificate != null && serverCfg.tlsKey != null));
416 message = ''
417 services.wstunnel.servers."${name}".tlsCertificate and services.wstunnel.servers."${name}".tlsKey need to be set together.
418 '';
419 }) cfg.servers) ++
420 (mapAttrsToList (name: clientCfg: {
421 assertion = !(clientCfg.localToRemote == [] && clientCfg.dynamicToRemote == null);
422 message = ''
423 Either one of services.wstunnel.clients."${name}".localToRemote or services.wstunnel.clients."${name}".dynamicToRemote must be set.
424 '';
425 }) cfg.clients);
426 };
427
428 meta.maintainers = with maintainers; [ alyaeanyx ];
429}