From a91aaae5cf38f0105ac7aa1ae3047c3351f29b33 Mon Sep 17 00:00:00 2001 From: h7x4 Date: Sat, 29 Jun 2024 02:14:31 +0200 Subject: [PATCH] {tsuki/dosei}: set up wstunnel --- hosts/dosei/configuration.nix | 1 + hosts/dosei/services/wstunnel.nix | 27 ++ hosts/tsuki/configuration.nix | 1 + hosts/tsuki/services/nginx/default.nix | 7 + hosts/tsuki/services/wstunnel.nix | 28 ++ modules/wstunnel.nix | 469 +++++++++++++++++++++++++ secrets/common.yaml | 6 +- 7 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 hosts/dosei/services/wstunnel.nix create mode 100644 hosts/tsuki/services/wstunnel.nix create mode 100644 modules/wstunnel.nix diff --git a/hosts/dosei/configuration.nix b/hosts/dosei/configuration.nix index e189580..96013af 100644 --- a/hosts/dosei/configuration.nix +++ b/hosts/dosei/configuration.nix @@ -6,6 +6,7 @@ ./services/avahi.nix ./services/docker.nix ./services/jenkins.nix + ./services/wstunnel.nix ]; boot.loader.systemd-boot.enable = true; diff --git a/hosts/dosei/services/wstunnel.nix b/hosts/dosei/services/wstunnel.nix new file mode 100644 index 0000000..2635229 --- /dev/null +++ b/hosts/dosei/services/wstunnel.nix @@ -0,0 +1,27 @@ +{ config, ... }: +{ + imports = [ + ../../../modules/wstunnel.nix + ]; + disabledModules = [ + "services/networking/wstunnel.nix" + ]; + + # NOTE: Contains + # - WSTUNNEL_HTTP_UPGRADE_PATH_PREFIX + # - WSTUNNEL_RESTRICT_HTTP_UPGRADE_PATH_PREFIX + sops.secrets."wstunnel/http-upgrade-path-prefix-envvars" = { + sopsFile = ../../../secrets/common.yaml; + }; + + services.wstunnel = { + enable = true; + clients."ws-tsuki" = { + connectTo = "wss://ws.nani.wtf"; + localToRemote = [ + "tcp://10022:localhost:22" + ]; + environmentFile = config.sops.secrets."wstunnel/http-upgrade-path-prefix-envvars".path; + }; + }; +} diff --git a/hosts/tsuki/configuration.nix b/hosts/tsuki/configuration.nix index 2d88392..e702e12 100644 --- a/hosts/tsuki/configuration.nix +++ b/hosts/tsuki/configuration.nix @@ -20,6 +20,7 @@ ./services/taskserver.nix ./services/vaultwarden.nix ./services/vscode-server.nix + ./services/wstunnel.nix ./services/scrapers/nhk-easy-news/default.nix ]; diff --git a/hosts/tsuki/services/nginx/default.nix b/hosts/tsuki/services/nginx/default.nix index d538440..fdbe9e4 100644 --- a/hosts/tsuki/services/nginx/default.nix +++ b/hosts/tsuki/services/nginx/default.nix @@ -51,6 +51,11 @@ "osuchan".servers."localhost:${s ports.osuchan}" = { }; "plex".servers."localhost:${s ports.plex}" = { }; "vaultwarden".servers."unix:${sa.vaultwarden.newSocketAddress}" = { }; + "wstunnel".servers = let + inherit (config.services.wstunnel.servers."ws-tsuki".listen) host port; + in { + "${host}:${s port}" = { }; + }; }; virtualHosts = let @@ -189,6 +194,8 @@ # }; # }) + (proxy ["ws"] "http://wstunnel" enableWebsockets) + (host ["h7x4-stickers"] {}) (host ["pingu-stickers"] {}) ])); diff --git a/hosts/tsuki/services/wstunnel.nix b/hosts/tsuki/services/wstunnel.nix new file mode 100644 index 0000000..5496d94 --- /dev/null +++ b/hosts/tsuki/services/wstunnel.nix @@ -0,0 +1,28 @@ +{ config, ... }: +{ + imports = [ + ../../../modules/wstunnel.nix + ]; + disabledModules = [ + "services/networking/wstunnel.nix" + ]; + + # NOTE: Contains + # - WSTUNNEL_HTTP_UPGRADE_PATH_PREFIX + # - WSTUNNEL_RESTRICT_HTTP_UPGRADE_PATH_PREFIX + sops.secrets."wstunnel/http-upgrade-path-prefix-envvars" = { + sopsFile = ../../../secrets/common.yaml; + }; + + services.wstunnel = { + enable = true; + servers."ws-tsuki" = { + listen = { + host = "127.0.0.1"; + port = 8789; + }; + enableHTTPS = false; + environmentFile = config.sops.secrets."wstunnel/http-upgrade-path-prefix-envvars".path; + }; + }; +} diff --git a/modules/wstunnel.nix b/modules/wstunnel.nix new file mode 100644 index 0000000..2c74f83 --- /dev/null +++ b/modules/wstunnel.nix @@ -0,0 +1,469 @@ +{ config +, lib +, pkgs +, ... +}: + +let + cfg = config.services.wstunnel; + + hostPortToString = { host, port }: "${host}:${toString port}"; + + hostPortSubmodule = { + options = { + host = lib.mkOption { + description = "The hostname."; + type = lib.types.str; + }; + port = lib.mkOption { + description = "The port."; + type = lib.types.port; + }; + }; + }; + + commonOptions = { + enable = lib.mkEnableOption "this `wstunnel` instance." // { + default = true; + }; + + package = lib.mkPackageOption pkgs "wstunnel" { }; + + autoStart = + lib.mkEnableOption "starting this wstunnel instance automatically." // { + default = true; + }; + + extraArgs = lib.mkOption { + description = '' + 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`. + ''; + type = with lib.types; attrsOf (either str bool); + default = { }; + example = { + "someNewOption" = true; + "someNewOptionWithValue" = "someValue"; + }; + }; + + loggingLevel = lib.mkOption { + description = '' + Passed to --log-lvl + + Control the log verbosity. i.e: TRACE, DEBUG, INFO, WARN, ERROR, OFF + For more details, checkout [EnvFilter](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#example-syntax) + ''; + type = lib.types.nullOr lib.types.str; + example = "INFO"; + default = null; + }; + + environmentFile = lib.mkOption { + description = '' + 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. + ''; + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/secrets/wstunnelSecrets"; + }; + }; + + serverSubmodule = { config, ... }: { + options = commonOptions // { + listen = lib.mkOption { + description = '' + 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. + ''; + type = lib.types.submodule hostPortSubmodule; + default = { + host = "0.0.0.0"; + port = if config.enableHTTPS then 443 else 80; + }; + defaultText = lib.literalExpression '' + { + host = "0.0.0.0"; + port = if enableHTTPS then 443 else 80; + } + ''; + }; + + restrictTo = lib.mkOption { + description = '' + Accepted traffic will be forwarded only to this service. + ''; + type = lib.types.listOf (lib.types.submodule hostPortSubmodule); + default = [ ]; + example = [{ + host = "127.0.0.1"; + port = 51820; + }]; + }; + + enableHTTPS = lib.mkOption { + description = "Use HTTPS for the tunnel server."; + type = lib.types.bool; + default = true; + }; + + tlsCertificate = lib.mkOption { + description = '' + TLS certificate to use instead of the hardcoded one in case of HTTPS connections. + Use together with `tlsKey`. + ''; + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/secrets/cert.pem"; + }; + + tlsKey = lib.mkOption { + description = '' + TLS key to use instead of the hardcoded on in case of HTTPS connections. + Use together with `tlsCertificate`. + ''; + type = lib.types.nullOr lib.types.path; + default = null; + example = "/var/lib/secrets/key.pem"; + }; + + useACMEHost = lib.mkOption { + description = '' + 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`. + ''; + type = lib.types.nullOr lib.types.str; + default = null; + example = "example.com"; + }; + }; + }; + + clientSubmodule = { config, ... }: { + options = commonOptions // { + connectTo = lib.mkOption { + description = "Server address and port to connect to."; + type = lib.types.str; + example = "https://wstunnel.server.com:8443"; + }; + + localToRemote = lib.mkOption { + description = ''Listen on local and forwards traffic from remote.''; + type = lib.types.listOf (lib.types.str); + default = [ ]; + example = [ + "tcp://1212:google.com:443" + "unix:///tmp/wstunnel.sock:g.com:443" + ]; + }; + + remoteToLocal = lib.mkOption { + description = "Listen on remote and forwards traffic from local. Only tcp is supported"; + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "tcp://1212:google.com:443" + "unix://wstunnel.sock:g.com:443" + ]; + }; + + addNetBind = lib.mkEnableOption "Whether add CAP_NET_BIND_SERVICE to the tunnel service, this should be enabled if you want to bind port < 1024"; + + httpProxy = lib.mkOption { + description = '' + Proxy to use to connect to the wstunnel server (`USER:PASS@HOST:PORT`). + + ::: {.warning} + 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=` and set + this option to `:$PROXY_PASSWORD@:`. + Note however that this will also locally leak the passwords at + runtime via e.g. /proc//cmdline. + ::: + ''; + type = lib.types.nullOr lib.types.str; + default = null; + }; + + soMark = lib.mkOption { + description = '' + 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. + ''; + type = lib.types.nullOr lib.types.ints.unsigned; + default = null; + }; + + upgradePathPrefix = lib.mkOption { + description = '' + 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. + ''; + type = lib.types.nullOr lib.types.str; + default = null; + example = "wstunnel"; + }; + + tlsSNI = lib.mkOption { + description = "Use this as the SNI while connecting via TLS. Useful for circumventing hostname-based firewalls."; + type = lib.types.nullOr lib.types.str; + default = null; + }; + + tlsVerifyCertificate = lib.mkOption { + 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."; + type = lib.types.bool; + default = true; + }; + + # The original argument name `websocketPingFrequency` is a misnomer, as the frequency is the inverse of the interval. + websocketPingInterval = lib.mkOption { + description = "Frequency at which the client will send websocket ping to the server."; + type = lib.types.nullOr lib.types.ints.unsigned; + default = null; + }; + + upgradeCredentials = lib.mkOption { + description = '' + Use these credentials to authenticate during the HTTP upgrade request + (Basic authorization type, `USER:[PASS]`). + + ::: {.warning} + 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=` and set this + option to `:$HTTP_PASSWORD`. + Note however that this will also locally leak the passwords at runtime + via e.g. /proc//cmdline. + ::: + ''; + type = lib.types.nullOr lib.types.str; + default = null; + }; + + customHeaders = lib.mkOption { + description = "Custom HTTP headers to send during the upgrade request."; + type = lib.types.attrsOf lib.types.str; + default = { }; + example = { + "X-Some-Header" = "some-value"; + }; + }; + }; + }; + + generateServerUnit = name: serverCfg: { + name = "wstunnel-server-${name}"; + value = + let + certConfig = config.security.acme.certs.${serverCfg.useACMEHost}; + in + { + description = "wstunnel server - ${name}"; + requires = [ "network.target" "network-online.target" ]; + after = [ "network.target" "network-online.target" ]; + wantedBy = lib.optional serverCfg.autoStart "multi-user.target"; + + environment.RUST_LOG = serverCfg.loggingLevel; + + serviceConfig = { + Type = "exec"; + EnvironmentFile = + lib.optional (serverCfg.environmentFile != null) serverCfg.environmentFile; + DynamicUser = true; + SupplementaryGroups = + lib.optional (serverCfg.useACMEHost != null) certConfig.group; + PrivateTmp = true; + AmbientCapabilities = + lib.optionals (serverCfg.listen.port < 1024) [ "CAP_NET_BIND_SERVICE" ]; + NoNewPrivileges = true; + RestrictNamespaces = "uts ipc pid user cgroup"; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + PrivateDevices = true; + RestrictSUIDSGID = true; + + Restart = "on-failure"; + RestartSec = 2; + RestartSteps = 20; + RestartMaxDelaySec = "5min"; + }; + + script = with serverCfg; '' + ${lib.getExe package} \ + server \ + ${lib.cli.toGNUCommandLineShell { } ( + lib.recursiveUpdate + { + restrict-to = map hostPortToString restrictTo; + # TODO: fix this upstream + # tls-certificate = if useACMEHost != null + # then "${certConfig.directory}/fullchain.pem" + # else "${tlsCertificate}"; + # tls-private-key = if useACMEHost != null + # then "${certConfig.directory}/key.pem" + # else "${tlsKey}"; + } + extraArgs + )} \ + ${lib.escapeShellArg "${if enableHTTPS then "wss" else "ws"}://${hostPortToString listen}"} + ''; + }; + }; + + generateClientUnit = name: clientCfg: { + name = "wstunnel-client-${name}"; + value = { + description = "wstunnel client - ${name}"; + requires = [ "network.target" "network-online.target" ]; + after = [ "network.target" "network-online.target" ]; + wantedBy = lib.optional clientCfg.autoStart "multi-user.target"; + + environment.RUST_LOG = clientCfg.loggingLevel; + + serviceConfig = { + Type = "exec"; + EnvironmentFile = + lib.optional (clientCfg.environmentFile != null) clientCfg.environmentFile; + DynamicUser = true; + PrivateTmp = true; + AmbientCapabilities = + (lib.optionals clientCfg.addNetBind [ "CAP_NET_BIND_SERVICE" ]) ++ + (lib.optionals (clientCfg.soMark != null) [ "CAP_NET_ADMIN" ]); + NoNewPrivileges = true; + RestrictNamespaces = "uts ipc pid user cgroup"; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + PrivateDevices = true; + RestrictSUIDSGID = true; + + Restart = "on-failure"; + RestartSec = 2; + RestartSteps = 20; + RestartMaxDelaySec = "5min"; + }; + + script = with clientCfg; '' + ${lib.getExe package} \ + client \ + ${lib.cli.toGNUCommandLineShell { } ( + lib.recursiveUpdate + { + local-to-remote = localToRemote; + remote-to-local = remoteToLocal; + http-headers = lib.mapAttrsToList (n: v: "${n}:${v}") customHeaders; + http-proxy = httpProxy; + socket-so-mark = soMark; + http-upgrade-path-prefix = upgradePathPrefix; + tls-sni-override = tlsSNI; + tls-verify-certificate = tlsVerifyCertificate; + websocket-ping-frequency-sec = websocketPingInterval; + http-upgrade-credentials = upgradeCredentials; + } + extraArgs + )} \ + ${lib.escapeShellArg connectTo} + ''; + }; + }; +in +{ + options.services.wstunnel = { + enable = lib.mkEnableOption "wstunnel"; + + servers = lib.mkOption { + description = "`wstunnel` servers to set up."; + type = lib.types.attrsOf (lib.types.submodule serverSubmodule); + default = { }; + example = { + "wg-tunnel" = { + listen = { + host = "0.0.0.0"; + port = 8080; + }; + enableHTTPS = true; + tlsCertificate = "/var/lib/secrets/fullchain.pem"; + tlsKey = "/var/lib/secrets/key.pem"; + restrictTo = [{ + host = "127.0.0.1"; + port = 51820; + }]; + }; + }; + }; + + clients = lib.mkOption { + description = "`wstunnel` clients to set up."; + type = lib.types.attrsOf (lib.types.submodule clientSubmodule); + default = { }; + example = { + "wg-tunnel" = { + connectTo = "wss://wstunnel.server.com:8443"; + localToRemote = [ + "tcp://1212:google.com:443" + "tcp://2:n.lan:4?proxy_protocol" + ]; + remoteToLocal = [ + "socks5://[::1]:1212" + "unix://wstunnel.sock:g.com:443" + ]; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services = + (lib.mapAttrs' generateServerUnit (lib.filterAttrs (n: v: v.enable) cfg.servers)) // + (lib.mapAttrs' generateClientUnit (lib.filterAttrs (n: v: v.enable) cfg.clients)); + + assertions = + (lib.mapAttrsToList + (name: serverCfg: { + assertion = + !(serverCfg.useACMEHost != null && serverCfg.tlsCertificate != null); + message = '' + Options services.wstunnel.servers."${name}".useACMEHost and services.wstunnel.servers."${name}".{tlsCertificate, tlsKey} are mutually exclusive. + ''; + }) + cfg.servers) ++ + + (lib.mapAttrsToList + (name: serverCfg: { + assertion = + (serverCfg.tlsCertificate == null && serverCfg.tlsKey == null) || + (serverCfg.tlsCertificate != null && serverCfg.tlsKey != null); + message = '' + services.wstunnel.servers."${name}".tlsCertificate and services.wstunnel.servers."${name}".tlsKey need to be set together. + ''; + }) + cfg.servers) ++ + + (lib.mapAttrsToList + (name: clientCfg: { + assertion = !(clientCfg.localToRemote == [ ] && clientCfg.remoteToLocal == [ ]); + message = '' + Either one of services.wstunnel.clients."${name}".localToRemote or services.wstunnel.clients."${name}".remoteToLocal must be set. + ''; + }) + cfg.clients); + }; + + meta.maintainers = with lib.maintainers; [ alyaeanyx rvdp neverbehave ]; +} diff --git a/secrets/common.yaml b/secrets/common.yaml index 57e4abd..06a40f8 100644 --- a/secrets/common.yaml +++ b/secrets/common.yaml @@ -1,5 +1,7 @@ nix: access-tokens: ENC[AES256_GCM,data:K1V98nx+w0uoOY9ONDxbaZT9jbEbMqpzyYWaSrQIYfo2bm1HLeTHPqp2rqRFIPu5gD/5SqY2FW4Pak92it4S7o9liiI=,iv:/c6Mr3WQsbW7nBaa5NIG3pzatSyC9UE5zDpKjuD/FG0=,tag:8V344qvOVrgh5XHlinuFyw==,type:str] +wstunnel: + http-upgrade-path-prefix-envvars: ENC[AES256_GCM,data:aS7Kvpj9aHtaiKZiakDuvdiDcVYFMkYv9FIH060Dbkahk6v+2bbxzgKcRtnDnLlphtGlZD7yWRcbvlYiG7Y5mRNS1X5PkspQwFKKnwGGHiWgfun9yxB6VHvPdb4W4SNA8QfRmqH4XmJUfDSPmZfh5Ggzhy7/74avC0vfqKBvQ+ml4fjqTmdS6EkFGrrUwIXFrjiCqdxnNYmp8I/L1b22R5YoY/JTsc4mG6N9s3B75GvsYI2EDG4vQ7EMyktd2CHsXJgNFRQUM+GzBbkO4VvG,iv:EbuV/2L+p4A+aloC6uQYiFFF7Lsz5A5RTGMuHMqtTpI=,tag:DThZOERbXuUdDJso7ertbg==,type:str] ssh: secret-config: home: ENC[AES256_GCM,data:eUfhQb6yYYV3951sdwZpA1f8k+79mm1bMYY4EP+tn1g7DEHXG9XHYKPL3FLJMkaaXSWv5jbBZ3zrGodJPMH9VbcFOjvSdz9u56DnmyeR3S7Pwgj1YbELDn9akeVRpjcB1w2k8hn2vNIY1MV4vg==,iv:LQpS168sxPVegrlPJNZrVZE+GsZAMxRSl4EaHO6FFxg=,tag:w5SNj7LkYd+22SbLVbtsDQ==,type:str] @@ -47,8 +49,8 @@ sops: UllPc1JCTXoxUERMM05abjhnR0g0d2sK/wyBVH6Dxris4TF05POtYQbWj4DWOeID RAdf30dDVtmg4qPwsHiIQ8f10gA1DrgIrcae0JS5VZcRLRw5/4+g9Q== -----END AGE ENCRYPTED FILE----- - lastmodified: "2024-06-25T18:25:28Z" - mac: ENC[AES256_GCM,data:GGjXTEHVHAWrr0QHc3O4bMpGi1wFge6AbK7XEwRiOqh4W1Zow2CEcfGZxW5TLLayfB9lXemeKtrZWsqBOCXtHkd670KbxxKInE3FvJbjME8ZODAMpknYX4BXBGt6ksC03Tm4ri1JIy1OxDVXG4qb8skNtna4YkIiUf+ErTihakA=,iv:YGKnVl9QCLLTqdQfpiTbv31vEGEoolzMWtyEFvJekYI=,tag:8j+dnOqHfupKTAl1GQ09Mg==,type:str] + lastmodified: "2024-06-28T13:06:09Z" + mac: ENC[AES256_GCM,data:H19kxVh+pcjGhD78WHQYMGQ/0HY/F4NF6sYCvjn4hqPfFTJDDcVJ7QFxm2LL4Zz/+KNcI3qvnXO/g/MyaVxyJMyKC9LFwHT/0TwMRW1uHSBahPASFYvN0/h+6hp6TI9/DPeWjKEGk+1j1tU665YpnqYdOtRUfQEB02fmLf2jSiw=,iv:b0b4m/SlGNXBQ3ulLhbSHngSLZiFipPV+yAD6MG8vAo=,tag:B3oHJkWlFpY+g6dVkApDMw==,type:str] pgp: - created_at: "2024-06-26T07:42:59Z" enc: |-