at 23.11-pre 18 kB view raw
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}