From 997726aecd47de0ef6a0fec04f255d0ef8de310b Mon Sep 17 00:00:00 2001 From: sid Date: Sun, 10 May 2026 22:54:27 +0200 Subject: [PATCH] initial ESS Community NixOS module --- README.md | 117 +++++++++ flake.nix | 2 +- hosts/ess-helm/default.nix | 4 +- hosts/ess-helm/ess-helm.nix | 12 + hosts/ess-helm/{services => }/nginx.nix | 7 +- hosts/ess-helm/{services => }/openssh.nix | 0 hosts/ess-helm/services/default.nix | 7 - hosts/ess-helm/services/ess.nix | 24 -- modules/nixos/default.nix | 1 + modules/nixos/ess-helm/default.nix | 301 ++++++++++++++++++++++ overlays/default.nix | 7 +- 11 files changed, 443 insertions(+), 39 deletions(-) create mode 100644 hosts/ess-helm/ess-helm.nix rename hosts/ess-helm/{services => }/nginx.nix (87%) rename hosts/ess-helm/{services => }/openssh.nix (100%) delete mode 100644 hosts/ess-helm/services/default.nix delete mode 100644 hosts/ess-helm/services/ess.nix create mode 100644 modules/nixos/ess-helm/default.nix diff --git a/README.md b/README.md index bb6dc3d..d5f42b3 100644 --- a/README.md +++ b/README.md @@ -1 +1,118 @@ # ess-helm-nixos + +NixOS configuration for hosting [Element Server Suite Community](https://element.io/server-suite/community) on a single VPS using K3s, Helm, and NixOS-managed nginx as the TLS-terminating reverse proxy. + +## Architecture + +``` +Internet + | + v :80 / :443 +NixOS nginx (TLS via Let's Encrypt / ACME) + | + v :30080 (plain HTTP) +K3s ingress-nginx + | + v ClusterIP +ESS Helm chart + ├── Synapse ess-helm.de + ├── MAS auth.ess-helm.de + ├── Element Web chat.ess-helm.de + ├── Element Admin admin.ess-helm.de + └── Matrix RTC SFU mrtc.ess-helm.de (:30001 TCP / :30002 UDP direct) +``` + +Matrix user IDs: `@user:ess-helm.de` + +## DNS setup + +Create the following **A records** pointing to the VPS public IP: + +Record | Type | Value +---|---|--- +`ess-helm.de` | A | `` +`auth.ess-helm.de` | A | `` +`chat.ess-helm.de` | A | `` +`admin.ess-helm.de` | A | `` +`mrtc.ess-helm.de` | A | `` + +## Firewall + +| Port | Protocol | Purpose | +|---|---|---| +| 80 | TCP | HTTP (ACME challenge + redirect to HTTPS) | +| 443 | TCP | HTTPS (all ESS services via nginx) | +| 30001 | TCP | Matrix RTC WebRTC TCP transport | +| 30002 | UDP | Matrix RTC WebRTC muxed UDP transport | + +## Verification + +- Element Web: [chat.ess-helm.de](https://chat.ess-helm.de) +- Federation tester: [federationtester.matrix.org/?server_name=ess-helm.de](https://federationtester.matrix.org/?server_name=ess-helm.de) +- Matrix client well-known: [ess-helm.de/.well-known/matrix/client](https://ess-helm.de/.well-known/matrix/client) + +## Usage + +```nix +# flake.nix +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + + ess-helm.url = "github:sid/ess-helm"; + ess-helm.inputs.nixpkgs.follows = "nixpkgs"; + + synix.url = "git+https://git.sid.ovh/sid/synix.git?ref=release-25.11"; + synix.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = { self, nixpkgs, ess-helm, synix, ... }: { + nixosConfigurations.my-server = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ess-helm.nixosModules.ess-helm + synix.nixosModules.nginx + ./configuration.nix + ]; + }; + }; +} +``` + +```nix +# configuration.nix +{ + services.ess-helm = { + enable = true; + serverName = "example.com"; + openFirewall = true; + configureNginx = true; + }; + + security.acme.defaults.email = "admin@example.com"; + services.nginx = { + enable = true; + openFirewall = true; + forceSSL = true; + }; +} +``` + +Subdomains default to `auth.`, `chat.`, `admin.`, `mrtc.`. Override individually if needed: + +```nix +services.ess-helm.elementWeb.subdomain = "element"; # -> element.example.com +``` + +Upgrade the ESS or ingress-nginx chart version: + +```nix +services.ess-helm.ess = { + version = "26.X.Y"; + hash = "..."; +}; +``` + +## References + +- [ESS Community](https://element.io/en/server-suite/community) diff --git a/flake.nix b/flake.nix index 5a23791..eff7302 100644 --- a/flake.nix +++ b/flake.nix @@ -58,7 +58,7 @@ ess-helm = mkNixosConfiguration "x86_64-linux" [ ./hosts/ess-helm ]; }; - packages = forAllSystems (pkgs: import ./pkgs pkgs); + packages = forAllSystems (pkgs: import ./pkgs { inherit pkgs inputs; }); devShells = forAllSystems (pkgs: { default = pkgs.mkShell { diff --git a/hosts/ess-helm/default.nix b/hosts/ess-helm/default.nix index 5fbf9d6..7d9f515 100644 --- a/hosts/ess-helm/default.nix +++ b/hosts/ess-helm/default.nix @@ -7,10 +7,12 @@ { imports = [ ./boot.nix + ./ess-helm.nix ./hardware.nix ./networking.nix + ./nginx.nix + ./openssh.nix ./packages.nix - ./services ./users.nix inputs.synix.nixosModules.common diff --git a/hosts/ess-helm/ess-helm.nix b/hosts/ess-helm/ess-helm.nix new file mode 100644 index 0000000..8d1d3f5 --- /dev/null +++ b/hosts/ess-helm/ess-helm.nix @@ -0,0 +1,12 @@ +{ outputs, ... }: + +{ + imports = [ outputs.nixosModules.ess-helm ]; + + services.ess-helm = { + enable = true; + openFirewall = true; + configureNginx = true; + serverName = "ess-helm.de"; + }; +} diff --git a/hosts/ess-helm/services/nginx.nix b/hosts/ess-helm/nginx.nix similarity index 87% rename from hosts/ess-helm/services/nginx.nix rename to hosts/ess-helm/nginx.nix index 04a2482..fa61b62 100644 --- a/hosts/ess-helm/services/nginx.nix +++ b/hosts/ess-helm/nginx.nix @@ -1,14 +1,11 @@ -{ - inputs, - ... -}: +{ inputs, ... }: { imports = [ inputs.synix.nixosModules.nginx ]; services.nginx = { enable = true; - forceSSL = true; openFirewall = true; + forceSSL = true; }; } diff --git a/hosts/ess-helm/services/openssh.nix b/hosts/ess-helm/openssh.nix similarity index 100% rename from hosts/ess-helm/services/openssh.nix rename to hosts/ess-helm/openssh.nix diff --git a/hosts/ess-helm/services/default.nix b/hosts/ess-helm/services/default.nix deleted file mode 100644 index cd29f64..0000000 --- a/hosts/ess-helm/services/default.nix +++ /dev/null @@ -1,7 +0,0 @@ -{ - imports = [ - ./ess.nix - ./nginx.nix - ./openssh.nix - ]; -} diff --git a/hosts/ess-helm/services/ess.nix b/hosts/ess-helm/services/ess.nix deleted file mode 100644 index 5cf55ad..0000000 --- a/hosts/ess-helm/services/ess.nix +++ /dev/null @@ -1,24 +0,0 @@ -{ pkgs, ... }: - -{ - services.k3s = { - enable = true; - role = "server"; - extraFlags = toString [ - "--disable traefik" - ]; - }; - - environment.systemPackages = with pkgs; [ - kubectl - kubernetes-helm - ]; - - # TODO - # system.activationScripts.install-ess = { - # text = '' - # ${pkgs.kubernetes-helm}/bin/helm upgrade --install ess element-hq/element-server-suite -f /path/to/values.yaml -n ess --create-namespace - # ''; - # deps = [ ]; - # }; -} diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index 28a636c..44974c2 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -1,3 +1,4 @@ { common = import ./common; + ess-helm = import ./ess-helm; } diff --git a/modules/nixos/ess-helm/default.nix b/modules/nixos/ess-helm/default.nix new file mode 100644 index 0000000..64516df --- /dev/null +++ b/modules/nixos/ess-helm/default.nix @@ -0,0 +1,301 @@ +{ + config, + lib, + ... +}: + +let + cfg = config.services.ess-helm; + + inherit (lib) + literalExpression + mkDefault + mkEnableOption + mkIf + mkOption + recursiveUpdate + types + ; + + chartModule = + { + defaultRepo, + defaultName, + defaultVersion, + defaultHash, + ... + }: + types.submodule { + options = { + repo = mkOption { + type = types.str; + default = defaultRepo; + description = "Helm chart repository URL. For OCI registries this must include the chart name (e.g. `oci://ghcr.io/org/repo/chart`)."; + }; + + name = mkOption { + type = types.str; + default = defaultName; + description = "Chart name. For HTTP repositories appended to the pull command. For OCI repositories only used to name the store derivation."; + }; + + version = mkOption { + type = types.str; + default = defaultVersion; + description = "Chart version to deploy."; + example = "26.5.0"; + }; + + hash = mkOption { + type = types.str; + default = defaultHash; + description = "SRI hash of the chart `.tgz` archive."; + example = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + }; + + extraValues = mkOption { + type = types.attrs; + default = { }; + description = "Additional Helm values merged on top of the module-generated ones"; + example = literalExpression '' + { + synapse.additional."user-config.yaml".config = "max_upload_size: 100M"; + } + ''; + }; + }; + }; + + serviceModule = + { defaultSubdomain }: + types.submodule ( + { config, ... }: + { + options = { + subdomain = mkOption { + type = types.str; + default = defaultSubdomain; + description = "Subdomain for this service. Full hostname is `.`, or just `` when empty (Synapse)."; + }; + + forceSSL = mkOption { + type = types.bool; + default = true; + description = "Redirect plain-HTTP requests to HTTPS."; + }; + + enableACME = mkOption { + type = types.bool; + description = "Obtain a Let's Encrypt certificate for this vhost. Defaults to the value of `forceSSL`."; + }; + }; + + config.enableACME = mkDefault config.forceSSL; + } + ); + + hostFor = svc: if svc.subdomain == "" then cfg.serverName else "${svc.subdomain}.${cfg.serverName}"; + + vhostFor = svc: { + inherit (svc) forceSSL enableACME; + locations."/" = { + proxyPass = "http://127.0.0.1:${toString cfg.ingressNginxPort}"; + proxyWebsockets = true; + extraConfig = '' + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_buffering off; + client_max_body_size 50M; + ''; + }; + }; + +in +{ + options.services.ess-helm = { + + enable = mkEnableOption "Element Server Suite Community"; + + serverName = mkOption { + type = types.str; + description = "Matrix server name — the domain part of every Matrix ID (`@user:`). Must point at this host and **cannot be changed** after the first deployment without wiping the database."; + example = "example.com"; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the Matrix RTC WebRTC ports in the firewall (TCP 30001, UDP 30002)."; + }; + + configureNginx = mkOption { + type = types.bool; + default = true; + description = "Configure nginx as a TLS-terminating reverse proxy for all ESS services, proxying to ingress-nginx on `ingressNginxPort`."; + }; + + ingressNginxPort = mkOption { + type = types.port; + default = 30080; + description = "NodePort for ingress-nginx plain HTTP. Must be in the Kubernetes NodePort range (30000–32767)."; + }; + + ingressNginxTlsPort = mkOption { + type = types.port; + default = 30443; + description = "NodePort reserved for ingress-nginx HTTPS (unused at runtime but required by the chart schema). Must be in the Kubernetes NodePort range (30000–32767)."; + }; + + ipFamily = mkOption { + type = types.enum [ + "ipv4" + "ipv6" + "dual" + ]; + default = "ipv4"; + description = "IP family for the ESS cluster networking."; + }; + + synapse = mkOption { + type = serviceModule { defaultSubdomain = ""; }; + default = { }; + description = "Synapse homeserver (served at the root serverName domain)."; + }; + + matrixAuthenticationService = mkOption { + type = serviceModule { defaultSubdomain = "auth"; }; + default = { }; + description = "Matrix Authentication Service (MAS)."; + }; + + elementWeb = mkOption { + type = serviceModule { defaultSubdomain = "chat"; }; + default = { }; + description = "Element Web client."; + }; + + elementAdmin = mkOption { + type = serviceModule { defaultSubdomain = "admin"; }; + default = { }; + description = "Element Admin console."; + }; + + matrixRTC = mkOption { + type = serviceModule { defaultSubdomain = "mrtc"; }; + default = { }; + description = "Matrix RTC SFU signalling endpoint."; + }; + + ess = mkOption { + type = chartModule { + name = "ess"; + defaultRepo = "oci://ghcr.io/element-hq/ess-helm/matrix-stack"; + defaultName = "matrix-stack"; + defaultVersion = "26.5.0"; + defaultHash = "sha256-2YfDk29c8MoEa7rJXnHiKgKVqL7I0mmIcbtqgh2tunU="; + }; + default = { }; + description = "ESS Community Helm chart pin and extra values."; + }; + + ingressNginx = mkOption { + type = chartModule { + name = "ingressNginx"; + defaultRepo = "https://kubernetes.github.io/ingress-nginx"; + defaultName = "ingress-nginx"; + defaultVersion = "4.15.1"; + defaultHash = "sha256-Pv8L0YFR1uaxxEFGNBBXFEPdoax4KSyxiTRmKN54Tww="; + }; + default = { }; + description = "ingress-nginx Helm chart pin and extra values."; + }; + + }; + + config = mkIf cfg.enable { + + services.k3s = { + enable = true; + role = "server"; + disable = [ "traefik" ]; # use ingress-nginx + + autoDeployCharts = { + + ingress-nginx = recursiveUpdate { + inherit (cfg.ingressNginx) + repo + name + version + hash + ; + targetNamespace = "ingress-nginx"; + createNamespace = true; + values = { + controller = { + ingressClassResource.default = true; + service = { + type = "NodePort"; + nodePorts = { + http = cfg.ingressNginxPort; + https = cfg.ingressNginxTlsPort; + }; + }; + config = { + use-forwarded-headers = "true"; + compute-full-forwarded-for = "true"; + proxy-read-timeout = "86400"; + proxy-send-timeout = "86400"; + proxy-body-size = "50m"; + }; + }; + }; + } { values = cfg.ingressNginx.extraValues; }; + + ess = recursiveUpdate { + inherit (cfg.ess) + repo + name + version + hash + ; + targetNamespace = "ess"; + createNamespace = true; + values = { + inherit (cfg) serverName; + ingress = { + className = "nginx"; + tlsEnabled = false; + }; + synapse.ingress.host = hostFor cfg.synapse; + matrixAuthenticationService.ingress.host = hostFor cfg.matrixAuthenticationService; + elementWeb.ingress.host = hostFor cfg.elementWeb; + elementAdmin.ingress.host = hostFor cfg.elementAdmin; + matrixRTC.ingress.host = hostFor cfg.matrixRTC; + networking.ipFamily = cfg.ipFamily; + }; + } { values = cfg.ess.extraValues; }; + + }; + }; + + services.nginx = mkIf cfg.configureNginx { + enable = true; + virtualHosts = { + "${hostFor cfg.synapse}" = vhostFor cfg.synapse; + "${hostFor cfg.matrixAuthenticationService}" = vhostFor cfg.matrixAuthenticationService; + "${hostFor cfg.elementWeb}" = vhostFor cfg.elementWeb; + "${hostFor cfg.elementAdmin}" = vhostFor cfg.elementAdmin; + "${hostFor cfg.matrixRTC}" = vhostFor cfg.matrixRTC; + }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ 30001 ]; + allowedUDPPorts = [ 30002 ]; + }; + + environment.variables.KUBECONFIG = "/etc/rancher/k3s/k3s.yaml"; + }; +} diff --git a/overlays/default.nix b/overlays/default.nix index 1d66a0f..edc35d9 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -7,7 +7,12 @@ }; # packages in `pkgs/` accessible through 'pkgs.local' - local-packages = final: _prev: { local = import ../pkgs { pkgs = final; }; }; + local-packages = final: _prev: { + local = import ../pkgs { + pkgs = final; + inherit inputs; + }; + }; # https://nixos.wiki/wiki/Overlays modifications =