├── .editorconfig ├── .github ├── CODEOWNERS ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json └── workflows │ ├── flake-check.yaml │ ├── post-compare-link.yaml │ └── template-sync.yaml ├── LICENSE ├── README.md ├── flake.lock ├── flake.nix ├── lib ├── debug-pkgs.nix ├── ldap.nix ├── modules.nix ├── nix.nix └── ssh.nix ├── modules ├── acme.nix ├── containers.nix ├── debugging.nix ├── default.nix ├── dex-session-cookie-password-connector-2.40.patch ├── dex-session-cookie-password-connector-2.41.patch ├── dex-session-cookie-password-connector-2.42.patch ├── firefox.nix ├── git.nix ├── gitea.nix ├── grafana.nix ├── haproxy.nix ├── harmonia.nix ├── hedgedoc.nix ├── home-assistant-create-person-when-credentials-exist.diff ├── home-assistant-no-cloud.diff ├── home-assistant.nix ├── hound.nix ├── hydra.nix ├── initrd.nix ├── intel.nix ├── ldap.nix ├── mailman.nix ├── mastodon-bird-ui.patch ├── mastodon.nix ├── matrix.nix ├── nextcloud.nix ├── nginx.nix ├── nix.nix ├── no-graphics-packages.nix ├── nvidia.nix ├── portunus-remove-add-group.diff ├── portunus.nix ├── postgres.nix ├── prometheus.nix ├── renovate.nix ├── simd.nix ├── slim.nix ├── ssh.nix ├── strace.nix ├── tmux.nix ├── users.nix ├── vaultwarden.nix ├── vim.nix └── zfs.nix └── tests └── default.nix /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{diff,patch}] 12 | end_of_line = unset 13 | insert_final_newline = unset 14 | trim_trailing_whitespace = unset 15 | 16 | [*.ts] 17 | quote_type = single 18 | 19 | [Makefile] 20 | indent_size = 1 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @SuperSandro2000 @MarcelCoding 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Things done 2 | 3 | - [ ] Made sure, no settings are changed by default 4 | - [ ] Tested changes on a real world deployment 5 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "default:pinDigestsDisabled", 6 | "mergeConfidence:all-badges" 7 | ], 8 | "assignees": [ 9 | "MarcelCoding", 10 | "SuperSandro2000" 11 | ], 12 | "dependencyDashboardOSVVulnerabilitySummary": "all", 13 | "nix": { 14 | "enabled": true 15 | }, 16 | "osvVulnerabilityAlerts": true, 17 | "packageRules": [ 18 | { 19 | "matchManagers": ["nix"], 20 | "groupName": "flake inputs" 21 | } 22 | ], 23 | "prHourlyLimit": 0, 24 | "schedule": [ 25 | "before 12am on sunday" 26 | ], 27 | "semanticCommits": "disabled" 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/flake-check.yaml: -------------------------------------------------------------------------------- 1 | name: "Run flake check" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ '*' ] 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | check: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | targets: 21 | - repo: NixOS/nixpkgs 22 | branch: nixos-25.05 23 | - repo: NixOS/nixpkgs 24 | branch: nixos-unstable 25 | 26 | - repo: NuschtOS/nuschtpkgs 27 | branch: nixos-25.05 28 | - repo: NuschtOS/nuschtpkgs 29 | branch: backports-25.05 30 | - repo: NuschtOS/nuschtpkgs 31 | branch: nixos-unstable 32 | 33 | - repo: SuperSandro2000/nixpkgs 34 | branch: nixos-unstable 35 | extraArgs: experimental-features = ca-derivations nix-command flakes 36 | 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | - uses: cachix/install-nix-action@v31 41 | with: 42 | extra_nix_config: | 43 | ${{ matrix.targets.extraArgs }} 44 | 45 | - name: eval plain config with ${{ matrix.targets.repo }}/${{ matrix.targets.branch }} 46 | if: matrix.targets.repo != 'NuschtOS/nuschtpkgs' || matrix.targets.branch != 'nixos-unstable' 47 | run: | 48 | nix eval -L .#checks.x86_64-linux.no-config \ 49 | --override-input nixpkgs github:${{ matrix.targets.repo }}/${{ matrix.targets.branch }} 50 | 51 | - name: run nix flake check with ${{ matrix.targets.repo }}/${{ matrix.targets.branch }} 52 | if: matrix.targets.repo != 'NuschtOS/nuschtpkgs' || matrix.targets.branch != 'nixos-unstable' 53 | run: | 54 | nix flake check \ 55 | --override-input nixpkgs github:${{ matrix.targets.repo }}/${{ matrix.targets.branch }} 56 | 57 | - name: eval plain config with flake.lock 58 | if: matrix.targets.repo == 'NuschtOS/nuschtpkgs' && matrix.targets.branch == 'nixos-unstable' 59 | run: | 60 | nix eval -L .#checks.x86_64-linux.no-config 61 | 62 | - name: run nix flake check with flake.lock 63 | if: matrix.targets.repo == 'NuschtOS/nuschtpkgs' && matrix.targets.branch == 'nixos-unstable' 64 | run: | 65 | nix flake check 66 | -------------------------------------------------------------------------------- /.github/workflows/post-compare-link.yaml: -------------------------------------------------------------------------------- 1 | name: Post compare link when flake.lock changes 2 | 3 | permissions: 4 | issues: write 5 | pull-requests: write 6 | 7 | on: 8 | pull_request: 9 | paths: ['flake.lock'] 10 | 11 | jobs: 12 | post-compare-link: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - uses: NuschtOS/flake-lock-compare-action@main 19 | -------------------------------------------------------------------------------- /.github/workflows/template-sync.yaml: -------------------------------------------------------------------------------- 1 | name: "Sync template" 2 | on: 3 | schedule: 4 | - cron: "0 0 1 * *" 5 | workflow_dispatch: 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | repo-sync: 13 | if: github.repository != 'NuschtOS/template' 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | token: ${{ secrets.GH_TOKEN_FOR_UPDATES }} 19 | - uses: AndreasAugustin/actions-template-sync@v2 20 | with: 21 | # required to update github workflow files 22 | github_token: ${{ secrets.GH_TOKEN_FOR_UPDATES }} 23 | pr_commit_msg: Merge template changes 24 | source_repo_path: NuschtOS/template 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 NüschtOS contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NixOS Modules 2 | 3 | Collection of opinionated, integrated and shared NixOS modules. 4 | 5 | This includes features like: 6 | - Backend independent LDAP/OAuth2 abstraction with service integration 7 | - A continuation of environment.noXLibs named environment.noGraphicsPackages 8 | - Easy Postgres upgrades between major versions and installation of `pg_stat_statements` extension in all databases 9 | - Easy integration of Matrix Synapse, Element Web and extra oembed providers 10 | - Configure extra dependencies in Nextcloud for the Recognize and Memories Apps and properly setup preview generation 11 | - Restricted nix remote builders which can only execute remote builds 12 | - More opinionated integrations on top of Portunus (Simple LDAP frontend), dex and oauth2-proxy 13 | 14 | and many smaller integrations like: 15 | 16 | - git-delta 17 | - Harmonia Nginx 18 | - Intel hardware acceleration 19 | - Mailman PostgreSQL 20 | - Nginx TCP fast open 21 | - Nix diff system on activation and dry-activation 22 | - easy configuration of HTTP/HTTPS targets in Prometheus blackbox exporter 23 | - Vaultwarden Nginx and Postgres 24 | - ... and much more! 25 | 26 | ## Usage 27 | 28 | Add or merge the following settings to your `flake.nix`: 29 | 30 | ```nix 31 | { 32 | inputs = { 33 | nixpkgs.url = "github:NuschtOS/nuschtpkgs/nixos-unstable"; 34 | nixos-modules = { 35 | url = "github:NuschtOS/nixos-modules"; 36 | inputs.nixpkgs.follows = "nixpkgs"; 37 | }; 38 | }; 39 | 40 | outputs = { nixos-modules, ... }: { 41 | nixosConfigurations.HOSTNAME = { 42 | modules = [ 43 | nixos-modules.nixosModule 44 | ]; 45 | }; 46 | } 47 | ``` 48 | 49 | If your `nixpkgs` input is named differently, update the `follows` to your name accordingly. 50 | 51 | By using `nixos-modules.nixosModule`, all available modules are imported. 52 | 53 | It is also possible to only import a subset of modules. 54 | Under `nixos-modules.nixosModules.` we expose all modules available in the modules directory. 55 | This requires manually providing `libS` as a module argument. 56 | The following snippet is equal to what adding all modules is doing: 57 | ```nix 58 | { 59 | _module.args.libS = lib.mkOverride 1000 (self.lib { inherit lib config; }); 60 | } 61 | ``` 62 | 63 | ## Available options 64 | 65 | Please use our [options search site](https://search.xn--nschtos-n2a.de/?scope=NixOS%20Modules) to find and browse all available options. It supports searching for option names, wildcards and can be [self hosted](https://github.com/NuschtOS/search), too. 66 | 67 | ## Compatibility note 68 | 69 | Sometimes we use options from yet-to-be-merged Nixpkgs pull requests. 70 | Normally that fails evaluation because lib.mkIf also checks the types if the condition is false. 71 | This can be hacked around by using `lib.optional*` or `if ... then ... else ...` but then the option does not work. 72 | To close that gap we offer a nixpkgs fork named [nüschtpkgs](https://github.com/NuschtOS/nuschtpkgs). 73 | It contains the latest stable branch and unstable and it is daily rebased. 74 | We use CI checks to ensure that the modules evaluate on the current stable and unstable branch and some selected forks. 75 | 76 | ## Design 77 | 78 | * Modules should never change the configuration without setting an option 79 | * Unless the global overwrite ``opinionatedDefaults = true`` is set which activates most settings. 80 | Unless you know what you are doing, you shouldn't really set this option. 81 | 82 | ## Similar projects 83 | 84 | * 85 | * 86 | 87 | ## Contact 88 | 89 | For bugs and issues please open an issue in this repository. 90 | 91 | If you want to chat about things or have ideas, feel free to join the [Matrix chat](https://matrix.to/#/#nuschtos:c3d2.de). 92 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1747542820, 24 | "narHash": "sha256-GaOZntlJ6gPPbbkTLjbd8BMWaDYafhuuYRNrxCGnPJw=", 25 | "owner": "NuschtOS", 26 | "repo": "nuschtpkgs", 27 | "rev": "292fa7d4f6519c074f0a50394dbbe69859bb6043", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NuschtOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nuschtpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Opinionated shared nixos configurations"; 3 | 4 | inputs = { 5 | flake-utils.url = "github:numtide/flake-utils"; 6 | # if changed, also update .github/workflows/flake-eval.yaml 7 | nixpkgs.url = "github:NuschtOS/nuschtpkgs/nixos-unstable"; 8 | }; 9 | 10 | outputs = { self, flake-utils, nixpkgs, ... }: 11 | let 12 | inherit (nixpkgs) lib; 13 | src = builtins.filterSource (path: type: type == "directory" || lib.hasSuffix ".nix" (baseNameOf path)) ./.; 14 | ls = dir: lib.attrNames (builtins.readDir (src + "/${dir}")); 15 | fileList = dir: map (file: ./. + "/${dir}/${file}") (ls dir); 16 | 17 | importDirToKey = dir: args: lib.listToAttrs (map 18 | (file: { 19 | name = lib.removeSuffix ".nix" file; 20 | value = import (./. + "/${dir}/${file}") args; 21 | }) 22 | (ls dir) 23 | ); 24 | in 25 | { 26 | lib = args: let 27 | lib' = importDirToKey "lib" args; 28 | in lib' // { 29 | # some functions get promoted to be directly under libS 30 | inherit (lib'.modules) mkOpinionatedOption mkRecursiveDefault; 31 | inherit (lib'.ssh) mkPubKey; 32 | }; 33 | 34 | # NOTE: requires libS to be imported once which can be done like: 35 | # _module.args.libS = lib.mkOverride 1001 (nixos-modules.lib { inherit lib config; }); 36 | nixosModules = lib.foldr (a: b: a // b) { } (map 37 | (name: { 38 | "${lib.removeSuffix ".nix" name}" = { 39 | imports = [ 40 | # this must match https://gitea.c3d2.de/c3d2/nix-user-module/src/branch/master/flake.nix#L17 aka modules/default.nix, 41 | # otherwise the module system does not dedupe the import 42 | ./modules/default.nix 43 | ./modules/${name} 44 | ]; 45 | }; 46 | }) 47 | (ls "modules") 48 | ); 49 | 50 | nixosModule = { config, lib, ... }: { 51 | _module.args.libS = lib.mkOverride 1000 (self.lib { inherit lib config; }); 52 | imports = fileList "modules"; 53 | }; 54 | } // flake-utils.lib.eachDefaultSystem (system: let 55 | pkgs = nixpkgs.legacyPackages.${system}; 56 | in { 57 | checks = import ./tests { inherit lib self system; }; 58 | 59 | packages = { 60 | default = self.packages.${system}.debugging; 61 | 62 | debugging = pkgs.symlinkJoin { 63 | name = "debugging-tools"; 64 | paths = import ./lib/debug-pkgs.nix pkgs; 65 | }; 66 | }; 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /lib/debug-pkgs.nix: -------------------------------------------------------------------------------- 1 | pkgs: 2 | 3 | with pkgs; [ 4 | fd 5 | htop 6 | iproute2 7 | lshw 8 | mtr 9 | nix-tree 10 | pciutils 11 | ripgrep 12 | strace 13 | tcpdump 14 | traceroute 15 | usbutils 16 | ] 17 | -------------------------------------------------------------------------------- /lib/ldap.nix: -------------------------------------------------------------------------------- 1 | { lib, ... }: 2 | 3 | { 4 | mkUserGroupOption = lib.mkOption { 5 | type = with lib.types; nullOr str; 6 | default = null; 7 | description = "Restrict logins to users in this group"; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /lib/modules.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | { 4 | mkOpinionatedOption = text: lib.mkOption { 5 | type = lib.types.bool; 6 | default = config.opinionatedDefaults; 7 | description = "Whether to ${text}."; 8 | }; 9 | 10 | mkRecursiveDefault = lib.mapAttrsRecursive (_: lib.mkDefault); 11 | } 12 | -------------------------------------------------------------------------------- /lib/nix.nix: -------------------------------------------------------------------------------- 1 | { lib, ... }: 2 | 3 | { 4 | # taken from https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/misc/nix-daemon.nix#L828-L832 5 | # a builder can run code for `gcc.arch` and inferior architectures 6 | gcc-system-features = arch: [ "gccarch-${arch}" ] 7 | ++ map (x: "gccarch-${x}") lib.systems.architectures.inferiors.${arch}; 8 | } 9 | -------------------------------------------------------------------------------- /lib/ssh.nix: -------------------------------------------------------------------------------- 1 | _: 2 | 3 | { 4 | mkPubKey = name: type: publicKey: { 5 | "${name}-${type}" = { 6 | hostNames = [ name ]; 7 | publicKey = "${type} ${publicKey}"; 8 | }; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /modules/acme.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | let 4 | cfg = config.security.acme; 5 | in 6 | { 7 | options.security.acme.staging = lib.mkOption { 8 | type = lib.types.bool; 9 | default = false; 10 | description = '' 11 | If set to true, use Let's Encrypt's staging environment instead of the production one. 12 | The staging environment has much higher rate limits but does *not* generate fully signed certificates. 13 | This is great for testing when the normla rate limit is hit fast and impacts other people on the same IP. 14 | See for more detail. 15 | ''; 16 | }; 17 | 18 | config = lib.mkIf cfg.staging { 19 | security.acme.defaults.server = "https://acme-staging-v02.api.letsencrypt.org/directory"; 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /modules/containers.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.virtualisation; 5 | cfgd = cfg.docker; 6 | cfgp = cfg.podman; 7 | in 8 | { 9 | options.virtualisation = { 10 | docker = { 11 | aggressiveAutoPrune = libS.mkOpinionatedOption "configure aggressive auto pruning which removes everything unreferenced by running containers. This includes named volumes and mounts should be used instead"; 12 | 13 | recommendedDefaults = libS.mkOpinionatedOption "set recommended and maintenance reducing default settings"; 14 | }; 15 | 16 | podman = { 17 | aggressiveAutoPrune = libS.mkOpinionatedOption "configure aggressive auto pruning which removes everything unreferenced by running containers. This includes named volumes and mounts should be used instead"; 18 | 19 | recommendedDefaults = libS.mkOpinionatedOption "set recommended and maintenance reducing default settings"; 20 | }; 21 | }; 22 | 23 | imports = [ 24 | (lib.mkRemovedOptionModule ["virtualisation" "docker" "aggresiveAutoPrune"] "use virtualisation.docker.aggressiveAutoPrune") 25 | ]; 26 | 27 | config = { 28 | virtualisation = let 29 | autoPruneFlags = [ 30 | "--all" 31 | "--force" 32 | "--volumes" 33 | ]; 34 | in { 35 | containers.registries.search = lib.mkIf cfgp.recommendedDefaults [ 36 | "docker.io" 37 | "quay.io" 38 | "ghcr.io" 39 | "gcr.io" 40 | ]; 41 | 42 | docker = { 43 | daemon.settings = let 44 | useIPTables = !config.networking.nftables.enable; 45 | in lib.mkIf cfgd.recommendedDefaults { 46 | fixed-cidr-v6 = "fd00::/80"; # TODO: is this a good idea for all networks? 47 | iptables = lib.mkIf useIPTables true; 48 | ip6tables = lib.mkIf useIPTables true; 49 | ipv6 = true; 50 | # userland proxy is slow, does not give back ports and if iptables/nftables is available it is just worse 51 | userland-proxy = lib.mkIf useIPTables false; 52 | }; 53 | 54 | autoPrune = lib.mkIf cfgd.aggressiveAutoPrune { 55 | enable = true; 56 | flags = autoPruneFlags; 57 | }; 58 | }; 59 | 60 | podman = { 61 | autoPrune = lib.mkIf cfgp.aggressiveAutoPrune { 62 | enable = true; 63 | flags = autoPruneFlags; 64 | }; 65 | defaultNetwork.settings.dns_enabled = lib.mkIf cfgp.recommendedDefaults true; 66 | }; 67 | }; 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /modules/debugging.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | 3 | let 4 | cfg = config.debugging; 5 | in 6 | { 7 | options = { 8 | debugging.enable = lib.mkEnableOption "common debugging tools"; 9 | }; 10 | 11 | config = lib.mkIf cfg.enable { 12 | environment.systemPackages = import ../lib/debug-pkgs.nix pkgs; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /modules/default.nix: -------------------------------------------------------------------------------- 1 | { lib, ... }: 2 | 3 | { 4 | options.opinionatedDefaults = lib.mkEnableOption "opinionated defaults. This option is *not* recommended to be set"; 5 | } 6 | -------------------------------------------------------------------------------- /modules/dex-session-cookie-password-connector-2.40.patch: -------------------------------------------------------------------------------- 1 | commit 6a1fedff8b81673aa984110d24ff9b181387a600 2 | Author: Julian Taylor 3 | Date: Sat Apr 18 20:04:35 2020 +0200 4 | 5 | Add session cookie to password connector login 6 | 7 | For the password connector store the identity and the approved scopes in 8 | a cookie per clientid. 9 | Configured via environment variables: 10 | 11 | DEX_SESSION_MAXAGE_SECONDS: maximum age of the session 12 | DEX_SESSION_AUTHKEY: 32 byte session authentication key 13 | DEX_SESSION_ENCKEY: 32 byte session encryption key 14 | 15 | diff --git a/go.mod b/go.mod 16 | index 4a1fd126..f5e16066 100644 17 | --- a/go.mod 18 | +++ b/go.mod 19 | @@ -20,6 +20,8 @@ require ( 20 | github.com/google/uuid v1.6.0 21 | github.com/gorilla/handlers v1.5.2 22 | github.com/gorilla/mux v1.8.1 23 | + github.com/gorilla/securecookie v1.1.2 24 | + github.com/gorilla/sessions v1.2.2 25 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 26 | github.com/kylelemons/godebug v1.1.0 27 | github.com/lib/pq v1.10.9 28 | diff --git a/go.sum b/go.sum 29 | index 0c546bb4..dc438c5f 100644 30 | --- a/go.sum 31 | +++ b/go.sum 32 | @@ -111,6 +111,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ 33 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 34 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 35 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 36 | +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 37 | +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= 39 | github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= 40 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | @@ -126,6 +128,10 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE 42 | github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= 43 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 44 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 45 | +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 46 | +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 47 | +github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 48 | +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 49 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= 50 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 51 | github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc= 52 | diff --git a/server/handlers.go b/server/handlers.go 53 | index 42f3ebe5..fcdd898d 100644 54 | --- a/server/handlers.go 55 | +++ b/server/handlers.go 56 | @@ -20,6 +20,7 @@ 57 | "github.com/coreos/go-oidc/v3/oidc" 58 | "github.com/go-jose/go-jose/v4" 59 | "github.com/gorilla/mux" 60 | + "github.com/gorilla/sessions" 61 | 62 | "github.com/dexidp/dex/connector" 63 | "github.com/dexidp/dex/server/internal" 64 | @@ -189,6 +190,67 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { 65 | } 66 | } 67 | 68 | +func (s *Server) getSession(r *http.Request, authReq storage.AuthRequest) *sessions.Session { 69 | + if authReq.ClientID == "" { 70 | + return nil 71 | + } 72 | + session, _ := s.sessionStore.Get(r, authReq.ClientID) 73 | + return session 74 | +} 75 | + 76 | +func (s *Server) getSessionIdentity(session *sessions.Session) (connector.Identity, bool) { 77 | + var identity connector.Identity 78 | + identityRaw, ok := session.Values["identity"].([]byte) 79 | + if !ok { 80 | + return identity, false 81 | + } 82 | + err := json.Unmarshal(identityRaw, &identity) 83 | + if err != nil { 84 | + return identity, false 85 | + } 86 | + return identity, true 87 | +} 88 | + 89 | +func (s *Server) sessionGetScopes(session *sessions.Session) map[string]bool { 90 | + scopesRaw, ok := session.Values["scopes"].([]byte) 91 | + if ok { 92 | + var scopes map[string]bool 93 | + err := json.Unmarshal(scopesRaw, &scopes) 94 | + if err == nil { 95 | + return scopes 96 | + } 97 | + } 98 | + return make(map[string]bool) 99 | +} 100 | + 101 | +func (s *Server) sessionScopesApproved(session *sessions.Session, authReq storage.AuthRequest) bool { 102 | + // check all scopes are approved in the session 103 | + scopes := s.sessionGetScopes(session) 104 | + for _, wantedScope := range authReq.Scopes { 105 | + _, ok := scopes[wantedScope] 106 | + if !ok { 107 | + return false 108 | + } 109 | + } 110 | + return true 111 | +} 112 | + 113 | +func (s *Server) authenticateSession(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { 114 | + // add scopes of the request to session scopes after approval 115 | + session := s.getSession(r, authReq) 116 | + scopes := s.sessionGetScopes(session) 117 | + for _, wantedScope := range authReq.Scopes { 118 | + scopes[wantedScope] = true 119 | + } 120 | + var err error 121 | + session.Values["scopes"], err = json.Marshal(scopes) 122 | + if err != nil { 123 | + s.logger.Error("failed to marshal scopes", "err", err) 124 | + } else { 125 | + session.Save(r, w) 126 | + } 127 | +} 128 | + 129 | func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { 130 | ctx := r.Context() 131 | authReq, err := s.parseAuthorizationRequest(r) 132 | @@ -266,15 +328,49 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { 133 | } 134 | http.Redirect(w, r, callbackURL, http.StatusFound) 135 | case connector.PasswordConnector: 136 | - loginURL := url.URL{ 137 | - Path: s.absPath("/auth", connID, "login"), 138 | - } 139 | - q := loginURL.Query() 140 | - q.Set("state", authReq.ID) 141 | - q.Set("back", backLink) 142 | - loginURL.RawQuery = q.Encode() 143 | + session := s.getSession(r, *authReq) 144 | + identity, idFound := s.getSessionIdentity(session) 145 | 146 | - http.Redirect(w, r, loginURL.String(), http.StatusFound) 147 | + if !idFound { 148 | + // no session id, do password request 149 | + loginURL := url.URL{ 150 | + Path: s.absPath("/auth", connID, "login"), 151 | + } 152 | + q := loginURL.Query() 153 | + q.Set("state", authReq.ID) 154 | + q.Set("back", backLink) 155 | + loginURL.RawQuery = q.Encode() 156 | + 157 | + http.Redirect(w, r, loginURL.String(), http.StatusFound) 158 | + } else { 159 | + // session id found skip the password prompt 160 | + redirectURL, canSkipApproval, err := s.finalizeLogin(ctx, identity, *authReq, conn) 161 | + if err != nil { 162 | + s.logger.Error("Failed to finalize login", "err", err) 163 | + s.renderError(r, w, http.StatusInternalServerError, "Login error.") 164 | + return 165 | + } 166 | + var hasApproval bool 167 | + if canSkipApproval { 168 | + hasApproval = true 169 | + } else { 170 | + // if all scopes are approved end, else ask for approval for new scopes 171 | + hasApproval = s.sessionScopesApproved(session, *authReq) 172 | + } 173 | + if hasApproval { 174 | + authReq, err := s.storage.GetAuthRequest(authReq.ID) 175 | + if err != nil { 176 | + s.logger.Error("Failed to get updated request", "err", err) 177 | + s.renderError(r, w, http.StatusInternalServerError, "Login error.") 178 | + return 179 | + } else { 180 | + s.sendCodeResponse(w, r, authReq) 181 | + return 182 | + } 183 | + } else { 184 | + http.Redirect(w, r, redirectURL, http.StatusSeeOther) 185 | + } 186 | + } 187 | case connector.SAMLConnector: 188 | action, value, err := conn.POSTData(scopes, authReq.ID) 189 | if err != nil { 190 | @@ -385,6 +481,15 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { 191 | return 192 | } 193 | 194 | + // store identity in session 195 | + session := s.getSession(r, authReq) 196 | + session.Values["identity"], err = json.Marshal(identity) 197 | + if err != nil { 198 | + s.logger.Error("failed to marshal identity", "err", err) 199 | + } else { 200 | + session.Save(r, w) 201 | + } 202 | + 203 | if canSkipApproval { 204 | authReq, err = s.storage.GetAuthRequest(authReq.ID) 205 | if err != nil { 206 | @@ -646,6 +751,7 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { 207 | s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") 208 | return 209 | } 210 | + s.authenticateSession(w, r, authReq) 211 | s.sendCodeResponse(w, r, authReq) 212 | } 213 | } 214 | diff --git a/server/server.go b/server/server.go 215 | index 5c1a97b8..ac2ea704 100644 216 | --- a/server/server.go 217 | +++ b/server/server.go 218 | @@ -45,6 +45,8 @@ 219 | "github.com/dexidp/dex/connector/saml" 220 | "github.com/dexidp/dex/storage" 221 | "github.com/dexidp/dex/web" 222 | + "github.com/gorilla/securecookie" 223 | + "github.com/gorilla/sessions" 224 | ) 225 | 226 | // LocalConnector is the local passwordDB connector which is an internal 227 | @@ -164,7 +166,8 @@ type Server struct { 228 | 229 | storage storage.Storage 230 | 231 | - mux http.Handler 232 | + mux http.Handler 233 | + sessionStore *sessions.CookieStore 234 | 235 | templates *templates 236 | 237 | @@ -290,6 +293,28 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) 238 | now = time.Now 239 | } 240 | 241 | + authKey := []byte(os.Getenv("DEX_SESSION_AUTHKEY")) 242 | + if len(authKey) == 0 { 243 | + authKey = securecookie.GenerateRandomKey(32) 244 | + } 245 | + encKey := []byte(os.Getenv("DEX_SESSION_ENCKEY")) 246 | + if len(encKey) == 0 { 247 | + encKey = securecookie.GenerateRandomKey(32) 248 | + } 249 | + sessionStore := sessions.NewCookieStore(authKey, encKey) 250 | + maxageEnv := os.Getenv("DEX_SESSION_MAXAGE_SECONDS") 251 | + if len(maxageEnv) > 0 { 252 | + maxage, err := strconv.Atoi(maxageEnv) 253 | + if err != nil { 254 | + return nil, fmt.Errorf("server: failed to load web static: %v", err) 255 | + } 256 | + sessionStore.MaxAge(maxage) 257 | + } 258 | + sessionStore.Options.HttpOnly = true 259 | + sessionStore.Options.Path = "/" 260 | + sessionStore.Options.Secure = true 261 | + sessionStore.Options.SameSite = http.SameSiteStrictMode 262 | + 263 | s := &Server{ 264 | issuerURL: *issuerURL, 265 | connectors: make(map[string]Connector), 266 | @@ -302,6 +327,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) 267 | refreshTokenPolicy: c.RefreshTokenPolicy, 268 | skipApproval: c.SkipApprovalScreen, 269 | alwaysShowLogin: c.AlwaysShowLoginScreen, 270 | + sessionStore: sessionStore, 271 | now: now, 272 | templates: tmpls, 273 | passwordConnector: c.PasswordConnector, 274 | -------------------------------------------------------------------------------- /modules/dex-session-cookie-password-connector-2.41.patch: -------------------------------------------------------------------------------- 1 | commit 6234ed5278a88b459908c2a2b83838090e5e5ead 2 | Author: Julian Taylor 3 | Date: Sat Apr 18 20:04:35 2020 +0200 4 | 5 | Add session cookie to password connector login 6 | 7 | For the password connector store the identity and the approved scopes in 8 | a cookie per clientid. 9 | Configured via environment variables: 10 | 11 | DEX_SESSION_MAXAGE_SECONDS: maximum age of the session 12 | DEX_SESSION_AUTHKEY: 32 byte session authentication key 13 | DEX_SESSION_ENCKEY: 32 byte session encryption key 14 | 15 | diff --git a/go.mod b/go.mod 16 | index 890cc8df..5528929c 100644 17 | --- a/go.mod 18 | +++ b/go.mod 19 | @@ -20,6 +20,8 @@ require ( 20 | github.com/google/uuid v1.6.0 21 | github.com/gorilla/handlers v1.5.2 22 | github.com/gorilla/mux v1.8.1 23 | + github.com/gorilla/securecookie v1.1.2 24 | + github.com/gorilla/sessions v1.2.2 25 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 26 | github.com/kylelemons/godebug v1.1.0 27 | github.com/lib/pq v1.10.9 28 | diff --git a/go.sum b/go.sum 29 | index da52911d..0002a06c 100644 30 | --- a/go.sum 31 | +++ b/go.sum 32 | @@ -111,6 +111,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ 33 | github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 34 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 35 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 36 | +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 37 | +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= 39 | github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= 40 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 41 | @@ -126,7 +128,11 @@ github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkM 42 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 43 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 44 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 45 | +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 46 | +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 47 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 48 | +github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= 49 | +github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 50 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= 51 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 52 | github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 53 | diff --git a/server/handlers.go b/server/handlers.go 54 | index 63cb6122..da07c366 100644 55 | --- a/server/handlers.go 56 | +++ b/server/handlers.go 57 | @@ -20,6 +20,7 @@ 58 | "github.com/coreos/go-oidc/v3/oidc" 59 | "github.com/go-jose/go-jose/v4" 60 | "github.com/gorilla/mux" 61 | + "github.com/gorilla/sessions" 62 | 63 | "github.com/dexidp/dex/connector" 64 | "github.com/dexidp/dex/server/internal" 65 | @@ -189,6 +190,67 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { 66 | } 67 | } 68 | 69 | +func (s *Server) getSession(r *http.Request, authReq storage.AuthRequest) *sessions.Session { 70 | + if authReq.ClientID == "" { 71 | + return nil 72 | + } 73 | + session, _ := s.sessionStore.Get(r, authReq.ClientID) 74 | + return session 75 | +} 76 | + 77 | +func (s *Server) getSessionIdentity(session *sessions.Session) (connector.Identity, bool) { 78 | + var identity connector.Identity 79 | + identityRaw, ok := session.Values["identity"].([]byte) 80 | + if !ok { 81 | + return identity, false 82 | + } 83 | + err := json.Unmarshal(identityRaw, &identity) 84 | + if err != nil { 85 | + return identity, false 86 | + } 87 | + return identity, true 88 | +} 89 | + 90 | +func (s *Server) sessionGetScopes(session *sessions.Session) map[string]bool { 91 | + scopesRaw, ok := session.Values["scopes"].([]byte) 92 | + if ok { 93 | + var scopes map[string]bool 94 | + err := json.Unmarshal(scopesRaw, &scopes) 95 | + if err == nil { 96 | + return scopes 97 | + } 98 | + } 99 | + return make(map[string]bool) 100 | +} 101 | + 102 | +func (s *Server) sessionScopesApproved(session *sessions.Session, authReq storage.AuthRequest) bool { 103 | + // check all scopes are approved in the session 104 | + scopes := s.sessionGetScopes(session) 105 | + for _, wantedScope := range authReq.Scopes { 106 | + _, ok := scopes[wantedScope] 107 | + if !ok { 108 | + return false 109 | + } 110 | + } 111 | + return true 112 | +} 113 | + 114 | +func (s *Server) authenticateSession(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { 115 | + // add scopes of the request to session scopes after approval 116 | + session := s.getSession(r, authReq) 117 | + scopes := s.sessionGetScopes(session) 118 | + for _, wantedScope := range authReq.Scopes { 119 | + scopes[wantedScope] = true 120 | + } 121 | + var err error 122 | + session.Values["scopes"], err = json.Marshal(scopes) 123 | + if err != nil { 124 | + s.logger.Error("failed to marshal scopes", "err", err) 125 | + } else { 126 | + session.Save(r, w) 127 | + } 128 | +} 129 | + 130 | func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { 131 | ctx := r.Context() 132 | authReq, err := s.parseAuthorizationRequest(r) 133 | @@ -266,15 +328,49 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { 134 | } 135 | http.Redirect(w, r, callbackURL, http.StatusFound) 136 | case connector.PasswordConnector: 137 | - loginURL := url.URL{ 138 | - Path: s.absPath("/auth", connID, "login"), 139 | - } 140 | - q := loginURL.Query() 141 | - q.Set("state", authReq.ID) 142 | - q.Set("back", backLink) 143 | - loginURL.RawQuery = q.Encode() 144 | + session := s.getSession(r, *authReq) 145 | + identity, idFound := s.getSessionIdentity(session) 146 | 147 | - http.Redirect(w, r, loginURL.String(), http.StatusFound) 148 | + if !idFound { 149 | + // no session id, do password request 150 | + loginURL := url.URL{ 151 | + Path: s.absPath("/auth", connID, "login"), 152 | + } 153 | + q := loginURL.Query() 154 | + q.Set("state", authReq.ID) 155 | + q.Set("back", backLink) 156 | + loginURL.RawQuery = q.Encode() 157 | + 158 | + http.Redirect(w, r, loginURL.String(), http.StatusFound) 159 | + } else { 160 | + // session id found skip the password prompt 161 | + redirectURL, canSkipApproval, err := s.finalizeLogin(ctx, identity, *authReq, conn) 162 | + if err != nil { 163 | + s.logger.Error("Failed to finalize login", "err", err) 164 | + s.renderError(r, w, http.StatusInternalServerError, "Login error.") 165 | + return 166 | + } 167 | + var hasApproval bool 168 | + if canSkipApproval { 169 | + hasApproval = true 170 | + } else { 171 | + // if all scopes are approved end, else ask for approval for new scopes 172 | + hasApproval = s.sessionScopesApproved(session, *authReq) 173 | + } 174 | + if hasApproval { 175 | + authReq, err := s.storage.GetAuthRequest(authReq.ID) 176 | + if err != nil { 177 | + s.logger.Error("Failed to get updated request", "err", err) 178 | + s.renderError(r, w, http.StatusInternalServerError, "Login error.") 179 | + return 180 | + } else { 181 | + s.sendCodeResponse(w, r, authReq) 182 | + return 183 | + } 184 | + } else { 185 | + http.Redirect(w, r, redirectURL, http.StatusSeeOther) 186 | + } 187 | + } 188 | case connector.SAMLConnector: 189 | action, value, err := conn.POSTData(scopes, authReq.ID) 190 | if err != nil { 191 | @@ -384,6 +480,15 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { 192 | return 193 | } 194 | 195 | + // store identity in session 196 | + session := s.getSession(r, authReq) 197 | + session.Values["identity"], err = json.Marshal(identity) 198 | + if err != nil { 199 | + s.logger.Error("failed to marshal identity", "err", err) 200 | + } else { 201 | + session.Save(r, w) 202 | + } 203 | + 204 | if canSkipApproval { 205 | authReq, err = s.storage.GetAuthRequest(authReq.ID) 206 | if err != nil { 207 | @@ -645,6 +750,7 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { 208 | s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") 209 | return 210 | } 211 | + s.authenticateSession(w, r, authReq) 212 | s.sendCodeResponse(w, r, authReq) 213 | } 214 | } 215 | diff --git a/server/server.go b/server/server.go 216 | index 1cf71c50..e1cefe0c 100644 217 | --- a/server/server.go 218 | +++ b/server/server.go 219 | @@ -48,6 +48,8 @@ 220 | "github.com/dexidp/dex/connector/saml" 221 | "github.com/dexidp/dex/storage" 222 | "github.com/dexidp/dex/web" 223 | + "github.com/gorilla/securecookie" 224 | + "github.com/gorilla/sessions" 225 | ) 226 | 227 | // LocalConnector is the local passwordDB connector which is an internal 228 | @@ -171,7 +173,8 @@ type Server struct { 229 | 230 | storage storage.Storage 231 | 232 | - mux http.Handler 233 | + mux http.Handler 234 | + sessionStore *sessions.CookieStore 235 | 236 | templates *templates 237 | 238 | @@ -297,6 +300,28 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) 239 | now = time.Now 240 | } 241 | 242 | + authKey := []byte(os.Getenv("DEX_SESSION_AUTHKEY")) 243 | + if len(authKey) == 0 { 244 | + authKey = securecookie.GenerateRandomKey(32) 245 | + } 246 | + encKey := []byte(os.Getenv("DEX_SESSION_ENCKEY")) 247 | + if len(encKey) == 0 { 248 | + encKey = securecookie.GenerateRandomKey(32) 249 | + } 250 | + sessionStore := sessions.NewCookieStore(authKey, encKey) 251 | + maxageEnv := os.Getenv("DEX_SESSION_MAXAGE_SECONDS") 252 | + if len(maxageEnv) > 0 { 253 | + maxage, err := strconv.Atoi(maxageEnv) 254 | + if err != nil { 255 | + return nil, fmt.Errorf("server: failed to load web static: %v", err) 256 | + } 257 | + sessionStore.MaxAge(maxage) 258 | + } 259 | + sessionStore.Options.HttpOnly = true 260 | + sessionStore.Options.Path = "/" 261 | + sessionStore.Options.Secure = true 262 | + sessionStore.Options.SameSite = http.SameSiteStrictMode 263 | + 264 | s := &Server{ 265 | issuerURL: *issuerURL, 266 | connectors: make(map[string]Connector), 267 | @@ -309,6 +334,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) 268 | refreshTokenPolicy: c.RefreshTokenPolicy, 269 | skipApproval: c.SkipApprovalScreen, 270 | alwaysShowLogin: c.AlwaysShowLoginScreen, 271 | + sessionStore: sessionStore, 272 | now: now, 273 | templates: tmpls, 274 | passwordConnector: c.PasswordConnector, 275 | -------------------------------------------------------------------------------- /modules/dex-session-cookie-password-connector-2.42.patch: -------------------------------------------------------------------------------- 1 | commit b57d6b3a9b8db9fcf2ea2e12ac2fd7412e19d9e0 2 | Author: Julian Taylor 3 | Date: Sat Apr 18 20:04:35 2020 +0200 4 | 5 | Add session cookie to password connector login 6 | 7 | For the password connector store the identity and the approved scopes in 8 | a cookie per clientid. 9 | Configured via environment variables: 10 | 11 | DEX_SESSION_MAXAGE_SECONDS: maximum age of the session 12 | DEX_SESSION_AUTHKEY: 32 byte session authentication key 13 | DEX_SESSION_ENCKEY: 32 byte session encryption key 14 | 15 | diff --git a/go.mod b/go.mod 16 | index 8404620f..19936a82 100644 17 | --- a/go.mod 18 | +++ b/go.mod 19 | @@ -19,6 +19,8 @@ require ( 20 | github.com/google/uuid v1.6.0 21 | github.com/gorilla/handlers v1.5.2 22 | github.com/gorilla/mux v1.8.1 23 | + github.com/gorilla/securecookie v1.1.2 24 | + github.com/gorilla/sessions v1.4.0 25 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 26 | github.com/kylelemons/godebug v1.1.0 27 | github.com/lib/pq v1.10.9 28 | diff --git a/go.sum b/go.sum 29 | index 5ba3d05b..e6504d9e 100644 30 | --- a/go.sum 31 | +++ b/go.sum 32 | @@ -88,6 +88,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek 33 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 34 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 35 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 36 | +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 37 | +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 38 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 39 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 40 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 41 | @@ -101,7 +103,11 @@ github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkM 42 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 43 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 44 | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= 45 | +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 46 | +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 47 | github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= 48 | +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 49 | +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 50 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= 51 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 52 | github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 53 | diff --git a/server/handlers.go b/server/handlers.go 54 | index a00b290b..5c00de68 100644 55 | --- a/server/handlers.go 56 | +++ b/server/handlers.go 57 | @@ -20,6 +20,7 @@ 58 | "github.com/coreos/go-oidc/v3/oidc" 59 | "github.com/go-jose/go-jose/v4" 60 | "github.com/gorilla/mux" 61 | + "github.com/gorilla/sessions" 62 | 63 | "github.com/dexidp/dex/connector" 64 | "github.com/dexidp/dex/server/internal" 65 | @@ -195,6 +196,67 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) { 66 | } 67 | } 68 | 69 | +func (s *Server) getSession(r *http.Request, authReq storage.AuthRequest) *sessions.Session { 70 | + if authReq.ClientID == "" { 71 | + return nil 72 | + } 73 | + session, _ := s.sessionStore.Get(r, authReq.ClientID) 74 | + return session 75 | +} 76 | + 77 | +func (s *Server) getSessionIdentity(session *sessions.Session) (connector.Identity, bool) { 78 | + var identity connector.Identity 79 | + identityRaw, ok := session.Values["identity"].([]byte) 80 | + if !ok { 81 | + return identity, false 82 | + } 83 | + err := json.Unmarshal(identityRaw, &identity) 84 | + if err != nil { 85 | + return identity, false 86 | + } 87 | + return identity, true 88 | +} 89 | + 90 | +func (s *Server) sessionGetScopes(session *sessions.Session) map[string]bool { 91 | + scopesRaw, ok := session.Values["scopes"].([]byte) 92 | + if ok { 93 | + var scopes map[string]bool 94 | + err := json.Unmarshal(scopesRaw, &scopes) 95 | + if err == nil { 96 | + return scopes 97 | + } 98 | + } 99 | + return make(map[string]bool) 100 | +} 101 | + 102 | +func (s *Server) sessionScopesApproved(session *sessions.Session, authReq storage.AuthRequest) bool { 103 | + // check all scopes are approved in the session 104 | + scopes := s.sessionGetScopes(session) 105 | + for _, wantedScope := range authReq.Scopes { 106 | + _, ok := scopes[wantedScope] 107 | + if !ok { 108 | + return false 109 | + } 110 | + } 111 | + return true 112 | +} 113 | + 114 | +func (s *Server) authenticateSession(w http.ResponseWriter, r *http.Request, authReq storage.AuthRequest) { 115 | + // add scopes of the request to session scopes after approval 116 | + session := s.getSession(r, authReq) 117 | + scopes := s.sessionGetScopes(session) 118 | + for _, wantedScope := range authReq.Scopes { 119 | + scopes[wantedScope] = true 120 | + } 121 | + var err error 122 | + session.Values["scopes"], err = json.Marshal(scopes) 123 | + if err != nil { 124 | + s.logger.Error("failed to marshal scopes", "err", err) 125 | + } else { 126 | + session.Save(r, w) 127 | + } 128 | +} 129 | + 130 | func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { 131 | ctx := r.Context() 132 | authReq, err := s.parseAuthorizationRequest(r) 133 | @@ -272,15 +334,49 @@ func (s *Server) handleConnectorLogin(w http.ResponseWriter, r *http.Request) { 134 | } 135 | http.Redirect(w, r, callbackURL, http.StatusFound) 136 | case connector.PasswordConnector: 137 | - loginURL := url.URL{ 138 | - Path: s.absPath("/auth", connID, "login"), 139 | - } 140 | - q := loginURL.Query() 141 | - q.Set("state", authReq.ID) 142 | - q.Set("back", backLink) 143 | - loginURL.RawQuery = q.Encode() 144 | + session := s.getSession(r, *authReq) 145 | + identity, idFound := s.getSessionIdentity(session) 146 | 147 | - http.Redirect(w, r, loginURL.String(), http.StatusFound) 148 | + if !idFound { 149 | + // no session id, do password request 150 | + loginURL := url.URL{ 151 | + Path: s.absPath("/auth", connID, "login"), 152 | + } 153 | + q := loginURL.Query() 154 | + q.Set("state", authReq.ID) 155 | + q.Set("back", backLink) 156 | + loginURL.RawQuery = q.Encode() 157 | + 158 | + http.Redirect(w, r, loginURL.String(), http.StatusFound) 159 | + } else { 160 | + // session id found skip the password prompt 161 | + redirectURL, canSkipApproval, err := s.finalizeLogin(ctx, identity, *authReq, conn) 162 | + if err != nil { 163 | + s.logger.Error("Failed to finalize login", "err", err) 164 | + s.renderError(r, w, http.StatusInternalServerError, "Login error.") 165 | + return 166 | + } 167 | + var hasApproval bool 168 | + if canSkipApproval { 169 | + hasApproval = true 170 | + } else { 171 | + // if all scopes are approved end, else ask for approval for new scopes 172 | + hasApproval = s.sessionScopesApproved(session, *authReq) 173 | + } 174 | + if hasApproval { 175 | + authReq, err := s.storage.GetAuthRequest(ctx, authReq.ID) 176 | + if err != nil { 177 | + s.logger.Error("Failed to get updated request", "err", err) 178 | + s.renderError(r, w, http.StatusInternalServerError, "Login error.") 179 | + return 180 | + } else { 181 | + s.sendCodeResponse(w, r, authReq) 182 | + return 183 | + } 184 | + } else { 185 | + http.Redirect(w, r, redirectURL, http.StatusSeeOther) 186 | + } 187 | + } 188 | case connector.SAMLConnector: 189 | action, value, err := conn.POSTData(scopes, authReq.ID) 190 | if err != nil { 191 | @@ -391,6 +487,15 @@ func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { 192 | return 193 | } 194 | 195 | + // store identity in session 196 | + session := s.getSession(r, authReq) 197 | + session.Values["identity"], err = json.Marshal(identity) 198 | + if err != nil { 199 | + s.logger.Error("failed to marshal identity", "err", err) 200 | + } else { 201 | + session.Save(r, w) 202 | + } 203 | + 204 | if canSkipApproval { 205 | authReq, err = s.storage.GetAuthRequest(ctx, authReq.ID) 206 | if err != nil { 207 | @@ -646,6 +751,7 @@ func (s *Server) handleApproval(w http.ResponseWriter, r *http.Request) { 208 | s.renderError(r, w, http.StatusInternalServerError, "Approval rejected.") 209 | return 210 | } 211 | + s.authenticateSession(w, r, authReq) 212 | s.sendCodeResponse(w, r, authReq) 213 | } 214 | } 215 | diff --git a/server/server.go b/server/server.go 216 | index 8c046296..ae315df9 100644 217 | --- a/server/server.go 218 | +++ b/server/server.go 219 | @@ -15,6 +15,7 @@ 220 | "os" 221 | "path" 222 | "sort" 223 | + "strconv" 224 | "strings" 225 | "sync" 226 | "sync/atomic" 227 | @@ -47,6 +48,8 @@ 228 | "github.com/dexidp/dex/connector/saml" 229 | "github.com/dexidp/dex/storage" 230 | "github.com/dexidp/dex/web" 231 | + "github.com/gorilla/securecookie" 232 | + "github.com/gorilla/sessions" 233 | ) 234 | 235 | // LocalConnector is the local passwordDB connector which is an internal 236 | @@ -170,7 +173,8 @@ type Server struct { 237 | 238 | storage storage.Storage 239 | 240 | - mux http.Handler 241 | + mux http.Handler 242 | + sessionStore *sessions.CookieStore 243 | 244 | templates *templates 245 | 246 | @@ -296,6 +300,28 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) 247 | now = time.Now 248 | } 249 | 250 | + authKey := []byte(os.Getenv("DEX_SESSION_AUTHKEY")) 251 | + if len(authKey) == 0 { 252 | + authKey = securecookie.GenerateRandomKey(32) 253 | + } 254 | + encKey := []byte(os.Getenv("DEX_SESSION_ENCKEY")) 255 | + if len(encKey) == 0 { 256 | + encKey = securecookie.GenerateRandomKey(32) 257 | + } 258 | + sessionStore := sessions.NewCookieStore(authKey, encKey) 259 | + maxageEnv := os.Getenv("DEX_SESSION_MAXAGE_SECONDS") 260 | + if len(maxageEnv) > 0 { 261 | + maxage, err := strconv.Atoi(maxageEnv) 262 | + if err != nil { 263 | + return nil, fmt.Errorf("server: failed to load web static: %v", err) 264 | + } 265 | + sessionStore.MaxAge(maxage) 266 | + } 267 | + sessionStore.Options.HttpOnly = true 268 | + sessionStore.Options.Path = "/" 269 | + sessionStore.Options.Secure = true 270 | + sessionStore.Options.SameSite = http.SameSiteStrictMode 271 | + 272 | s := &Server{ 273 | issuerURL: *issuerURL, 274 | connectors: make(map[string]Connector), 275 | @@ -308,6 +334,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy) 276 | refreshTokenPolicy: c.RefreshTokenPolicy, 277 | skipApproval: c.SkipApprovalScreen, 278 | alwaysShowLogin: c.AlwaysShowLoginScreen, 279 | + sessionStore: sessionStore, 280 | now: now, 281 | templates: tmpls, 282 | passwordConnector: c.PasswordConnector, 283 | -------------------------------------------------------------------------------- /modules/firefox.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | let 4 | cfg = config.programs.firefox; 5 | in 6 | { 7 | options.programs.firefox = { 8 | hardwareAcceleration = lib.mkEnableOption "Firefox hardware acceleration" // { 9 | default = lib.hasAttr "driver" (config.hardware.intelgpu or { }); 10 | }; 11 | }; 12 | 13 | config = lib.mkIf cfg.hardwareAcceleration { 14 | environment = { 15 | # source https://github.com/elFarto/nvidia-vaapi-driver#firefox 16 | etc."libva.conf".text = '' 17 | LIBVA_MESSAGING_LEVEL=1 18 | ''; 19 | 20 | # source https://github.com/elFarto/nvidia-vaapi-driver#firefox 21 | sessionVariables.MOZ_DISABLE_RDD_SANDBOX = 1; 22 | }; 23 | 24 | programs.firefox.preferences = { 25 | # source https://github.com/elFarto/nvidia-vaapi-driver#firefox 26 | "media.ffmpeg.vaapi.enabled" = true; 27 | "media.rdd-ffmpeg.enabled" = true; 28 | "gfx.x11-egl.force-enabled" = true; 29 | }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /modules/git.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.programs.git; 5 | in 6 | { 7 | options.programs.git = { 8 | configureDelta = lib.mkEnableOption "" // { description = "Whether to configure delta, a syntax-highlighting pager for git."; }; 9 | 10 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 11 | }; 12 | 13 | config = { 14 | programs.git.config = lib.mkMerge [ 15 | (lib.mkIf cfg.recommendedDefaults { 16 | aliases = { 17 | ci = "commit"; 18 | co = "checkout"; 19 | st = "status"; 20 | }; 21 | interactive.singlekey = true; 22 | pull.rebase = true; 23 | }) 24 | 25 | # https://github.com/dandavison/delta?tab=readme-ov-file#get-started 26 | (lib.mkIf cfg.configureDelta { 27 | core.pager = "delta"; 28 | delta = { 29 | features = "line-numbers decorations relative-paths"; 30 | light = false; 31 | navigate = true; 32 | relative-paths = true; 33 | whitespace-error-style = "22 reverse"; 34 | }; 35 | "delta decorations" = { 36 | commit-decoration-style = "bold yellow box ul"; 37 | file-decoration-style = "none"; 38 | file-style = "bold yellow"; 39 | map-styles = "bold purple => normal bold rebeccapurple, bold cyan => syntax bold darkslategray"; 40 | }; 41 | diff.colorMoved = "default"; 42 | interactive.diffFilter = "delta --color-only"; 43 | merge.conflictstyle = "diff3"; 44 | }) 45 | ]; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /modules/gitea.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.services.gitea; 5 | cfgl = cfg.ldap; 6 | cfgo = cfg.oidc; 7 | inherit (config.security) ldap; 8 | 9 | mkOptStr = lib.mkOption { 10 | type = lib.types.nullOr lib.types.str; 11 | default = null; 12 | }; 13 | mkOptBool = lib.mkOption { 14 | type = lib.types.bool; 15 | default = false; 16 | }; 17 | in 18 | { 19 | options = { 20 | services.gitea = { 21 | # based on https://github.com/majewsky/nixos-modules/blob/master/gitea.nix 22 | ldap = { 23 | enable = lib.mkEnableOption "login via ldap"; 24 | 25 | adminGroup = lib.mkOption { 26 | type = with lib.types; nullOr str; 27 | default = null; 28 | example = "gitea-admins"; 29 | description = "Name of the ldap group that grants admin access in gitea."; 30 | }; 31 | 32 | searchUserPasswordFile = lib.mkOption { 33 | type = with lib.types; nullOr str; 34 | example = "/var/lib/secrets/search-user-password"; 35 | description = "Path to a file containing the password for the search/bind user."; 36 | }; 37 | 38 | userGroup = libS.ldap.mkUserGroupOption; 39 | 40 | options = { 41 | id = lib.mkOption { 42 | type = lib.types.ints.unsigned; 43 | default = 1; 44 | }; 45 | name = lib.mkOption { 46 | type = lib.types.str; 47 | }; 48 | security-protocol = mkOptStr; 49 | host = mkOptStr; 50 | port = lib.mkOption { 51 | type = lib.types.port; 52 | default = null; 53 | }; 54 | bind-dn = mkOptStr; 55 | bind-password = mkOptStr; 56 | user-search-base = mkOptStr; 57 | user-filter = mkOptStr; 58 | admin-filter = mkOptStr; 59 | username-attribute = mkOptStr; 60 | firstname-attribute = mkOptStr; 61 | surname-attribute = mkOptStr; 62 | email-attribute = mkOptStr; 63 | public-ssh-key-attribute = mkOptStr; 64 | avatar-attribute = mkOptStr; 65 | # TODO: enable LDAP groups 66 | page-size = lib.mkOption { 67 | type = lib.types.ints.unsigned; 68 | default = 0; 69 | }; 70 | attributes-in-bind = mkOptBool; 71 | skip-local-2fa = mkOptBool; 72 | allow-deactivate-all = mkOptBool; 73 | synchronize-users = mkOptBool; 74 | }; 75 | }; 76 | 77 | oidc = { 78 | enable = lib.mkEnableOption "login via OIDC through Dex and Portunus"; 79 | 80 | adminGroup = lib.mkOption { 81 | type = with lib.types; nullOr str; 82 | default = null; 83 | example = "gitea-admins"; 84 | description = "Name of the ldap group that grants admin access in gitea."; 85 | }; 86 | 87 | clientSecretFile = lib.mkOption { 88 | type = with lib.types; nullOr str; 89 | example = "/var/lib/secrets/search-user-password"; 90 | description = "Path to a file containing the password for the search/bind user."; 91 | }; 92 | 93 | userGroup = libS.ldap.mkUserGroupOption; 94 | 95 | options = { 96 | id = lib.mkOption { 97 | type = lib.types.ints.unsigned; 98 | default = 2; 99 | }; 100 | name = lib.mkOption { 101 | type = lib.types.str; 102 | }; 103 | provider = mkOptStr; 104 | key = mkOptStr; 105 | secret = mkOptStr; 106 | icon-url = mkOptStr; 107 | auto-discover-url = mkOptStr; 108 | skip-local-2fa = mkOptBool; 109 | scopes = mkOptStr; 110 | required-claim-name = mkOptStr; 111 | required-claim-value = mkOptStr; 112 | group-claim-name = mkOptStr; 113 | admin-group = mkOptStr; 114 | restricted-group = mkOptStr; 115 | group-team-map = mkOptStr; 116 | group-team-map-removal = mkOptBool; 117 | }; 118 | }; 119 | 120 | recommendedDefaults = libS.mkOpinionatedOption "set recommended, secure default settings"; 121 | }; 122 | }; 123 | 124 | imports = [ 125 | (lib.mkRenamedOptionModule [ "services" "gitea" "ldap" "bindPasswordFile" ] [ "services" "gitea" "ldap" "searchUserPasswordFile" ]) 126 | ]; 127 | 128 | config.services.gitea = lib.mkIf cfg.enable { 129 | ldap.options = lib.mkIf cfgl.enable { 130 | name = "ldap"; 131 | security-protocol = "LDAPS"; 132 | host = ldap.domainName; 133 | inherit (ldap) port; 134 | bind-dn = ldap.bindDN; 135 | bind-password = ''"$(cat ${cfgl.searchUserPasswordFile})"''; 136 | user-search-base = ldap.userBaseDN; 137 | user-filter = ldap.searchFilterWithGroupFilter cfgl.userGroup (ldap.userFilter "%[1]s"); 138 | admin-filter = ldap.groupFilter cfgl.adminGroup; 139 | username-attribute = ldap.userField; 140 | firstname-attribute = ldap.givenNameField; 141 | surname-attribute = ldap.surnameField; 142 | email-attribute = ldap.mailField; 143 | public-ssh-key-attribute = ldap.sshPublicKeyField; 144 | attributes-in-bind = true; 145 | synchronize-users = true; 146 | }; 147 | 148 | oidc.options = lib.mkIf cfgo.enable { 149 | name = config.services.portunus.webDomain; 150 | provider = "openidConnect"; 151 | key = "gitea"; 152 | secret = ''"$(cat ${cfgo.clientSecretFile})"''; 153 | icon-url = "${config.services.dex.settings.issuer}/theme/favicon.png"; 154 | auto-discover-url = config.services.dex.discoveryEndpoint; 155 | scopes = "groups"; 156 | required-claim-name = "groups"; 157 | required-claim-value = cfgo.userGroup; 158 | group-claim-name = "groups"; 159 | admin-group = cfgo.adminGroup; 160 | }; 161 | 162 | settings = lib.mkMerge [ 163 | (lib.mkIf cfg.oidc.enable { 164 | oauth2_client = { 165 | # only fully trust oidc when we also use LDAP login 166 | ACCOUNT_LINKING = lib.mkIf cfg.ldap.enable "auto"; 167 | ENABLE_AUTO_REGISTRATION = true; 168 | # email is required for auto registration 169 | # profile is required for preferred_username 170 | # groups are checked in authentication source but somehow still required here?! If missing, users get a signin prohbited 171 | OPENID_CONNECT_SCOPES = "email profile groups"; 172 | UPDATE_AVATAR = true; 173 | USERNAME = "preferred_username"; 174 | }; 175 | }) 176 | 177 | (lib.mkIf cfg.recommendedDefaults (libS.modules.mkRecursiveDefault { 178 | cors = { 179 | ALLOW_DOMAIN = cfg.settings.server.DOMAIN; 180 | ENABLED = true; 181 | }; 182 | cron.ENABLED = true; 183 | "cron.archive_cleanup" = { 184 | SCHEDULE = "@every 3h"; 185 | OLDER_THAN = "6h"; 186 | }; 187 | "cron.delete_old_actions".ENABLED = true; 188 | "cron.delete_old_system_notices".ENABLED = true; 189 | # TODO: upstream? 190 | "cron.resync_all_sshkeys" = { 191 | ENABLED = true; 192 | RUN_AT_START = true; 193 | }; 194 | log = { 195 | "logger.router.MODE" = "console-warn"; 196 | "logger.xorm.MODE" = "console-warn"; 197 | }; 198 | "log.console-warn" = { 199 | FLAGS = "stdflags"; 200 | LEVEL = "Warn"; 201 | MODE = "console"; 202 | }; 203 | other.SHOW_FOOTER_VERSION = false; 204 | repository.ACCESS_CONTROL_ALLOW_ORIGIN = cfg.settings.server.DOMAIN; 205 | "repository.signing".DEFAULT_TRUST_MODEL = "committer"; 206 | security.DISABLE_GIT_HOOKS = true; 207 | server = { 208 | ENABLE_GZIP = true; 209 | # The description of this setting is wrong and it doesn't control any CDN functionality but acts just as an override to the avatar federation. 210 | # see https://github.com/go-gitea/gitea/issues/31112 211 | OFFLINE_MODE = false; 212 | ROOT_URL = "https://${cfg.settings.server.DOMAIN}/"; 213 | SSH_SERVER_CIPHERS = "chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes128-gcm@openssh.com"; 214 | SSH_SERVER_KEY_EXCHANGES = "curve25519-sha256@libssh.org, ecdh-sha2-nistp521, ecdh-sha2-nistp384, ecdh-sha2-nistp256, diffie-hellman-group14-sha1"; 215 | SSH_SERVER_MACS = "hmac-sha2-256-etm@openssh.com, hmac-sha2-256, hmac-sha1"; 216 | }; 217 | session = { 218 | COOKIE_SECURE = true; 219 | PROVIDER = "db"; 220 | SAME_SITE = "strict"; 221 | SESSION_LIFE_TIME = 28 * 86400; # 28 days 222 | }; 223 | "ssh.minimum_key_sizes" = { 224 | ECDSA = -1; 225 | RSA = 4095; 226 | }; 227 | time.DEFAULT_UI_LOCATION = config.time.timeZone; 228 | })) 229 | ]; 230 | }; 231 | 232 | config.services.portunus.dex = lib.mkIf cfg.oidc.enable { 233 | enable = true; 234 | oidcClients = [{ 235 | callbackURL = "https://${cfg.settings.server.DOMAIN}/user/oauth2/${cfgo.options.name}/callback"; 236 | id = "gitea"; 237 | }]; 238 | }; 239 | 240 | config.services.portunus.seedSettings.groups = [ 241 | (lib.mkIf (cfgl.adminGroup != null) { 242 | long_name = "Gitea Administrators"; 243 | name = cfgl.adminGroup; 244 | permissions = { }; 245 | }) 246 | (lib.mkIf (cfgl.userGroup != null) { 247 | long_name = "Gitea Users"; 248 | name = cfgl.userGroup; 249 | permissions = { }; 250 | }) 251 | ]; 252 | 253 | config.systemd.services = lib.mkIf (cfg.enable && (cfgl.enable || cfgo.enable)) { 254 | gitea.preStart = 255 | let 256 | exe = lib.getExe cfg.package; 257 | # Return the option as an argument except if it is null or a special boolean type, then look if the value is truthy. 258 | # Also escape it unless it is going to execute shellcode. 259 | formatOption = key: value: if (value == null) then "" 260 | else if (builtins.isBool value) then (lib.optionalString value "--${key}") 261 | # allow executing shell after the --bind-password argument to e.g. cat a password file 262 | else "--${key} ${(if (key == "bind-password" || key == "secret") then value else lib.escapeShellArg value)}"; 263 | optionsStr = opt: lib.concatStringsSep " " (lib.mapAttrsToList formatOption opt); 264 | in 265 | lib.mkAfter (lib.optionalString cfgl.enable '' 266 | if ${exe} admin auth list | grep -q ${cfgl.options.name}; then 267 | ${exe} admin auth update-ldap ${optionsStr cfgl.options} 268 | else 269 | ${exe} admin auth add-ldap ${optionsStr (lib.filterAttrs (name: _: name != "id") cfgl.options)} 270 | fi 271 | '' + lib.optionalString cfgo.enable '' 272 | if ${exe} admin auth list | grep -q ${cfgo.options.name}; then 273 | ${exe} admin auth update-oauth ${optionsStr cfgo.options} 274 | else 275 | ${exe} admin auth add-oauth ${optionsStr (lib.filterAttrs (name: _: name != "id") cfgo.options)} 276 | fi 277 | ''); 278 | }; 279 | } 280 | -------------------------------------------------------------------------------- /modules/grafana.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.services.grafana; 5 | in 6 | { 7 | options = { 8 | services.grafana = { 9 | configureNginx = lib.mkOption { 10 | type = lib.types.bool; 11 | default = false; 12 | description = "Whether to configure Nginx."; 13 | }; 14 | 15 | configurePostgres = lib.mkOption { 16 | type = lib.types.bool; 17 | default = false; 18 | example = true; 19 | description = "Whether to configure and create a local PostgreSQL database."; 20 | }; 21 | 22 | oauth = { 23 | enable = lib.mkEnableOption "login only via OAuth2"; 24 | enableViewerRole = lib.mkOption { 25 | type = lib.types.bool; 26 | default = false; 27 | description = "Whether to enable the fallback Viewer role when users do not have the user- or adminGroup."; 28 | }; 29 | adminGroup = libS.ldap.mkUserGroupOption; 30 | userGroup = libS.ldap.mkUserGroupOption; 31 | }; 32 | 33 | recommendedDefaults = libS.mkOpinionatedOption "set recommended and secure default settings"; 34 | }; 35 | }; 36 | 37 | imports = [ 38 | (lib.mkRemovedOptionModule [ "services" "grafana" "configureRedis" ] '' 39 | The configureRedis option has been removed, as it only caches session which is normally not required for a small to medium sized instance. 40 | '') 41 | ]; 42 | 43 | config = { 44 | # the default values are hardcoded instead of using options. because I couldn't figure out how to extract them from the freeform type 45 | assertions = lib.mkIf cfg.enable [ 46 | { 47 | assertion = cfg.oauth.enable -> cfg.settings."auth.generic_oauth".client_secret != null; 48 | message = '' 49 | Setting services.grafana.oauth.enable to true requires to set services.grafana.settings."auth.generic_oauth".client_secret. 50 | Use this `$__file{/path/to/some/secret}` syntax to reference secrets securely. 51 | ''; 52 | } 53 | { 54 | assertion = cfg.settings.security.secret_key != "SW2YcwTIb9zpOOhoPsMm"; 55 | message = "services.grafana.settings.security.secret_key must be changed from it's insecure, default value!"; 56 | } 57 | { 58 | assertion = cfg.settings.security.disable_initial_admin_creation || cfg.settings.security.admin_password != "admin"; 59 | message = "services.grafana.settings.security.admin_password must be changed from it's insecure, default value!"; 60 | } 61 | ]; 62 | 63 | services.grafana.settings = lib.mkMerge [ 64 | (lib.mkIf (cfg.enable && cfg.recommendedDefaults) (libS.modules.mkRecursiveDefault { 65 | # no analytics, sorry, not sorry 66 | analytics = { 67 | feedback_links_enabled = false; 68 | reporting_enabled = false; 69 | }; 70 | log.level = "warn"; 71 | security = { 72 | cookie_secure = true; 73 | content_security_policy = true; 74 | strict_transport_security = true; 75 | }; 76 | server = { 77 | enable_gzip = true; 78 | root_url = "https://${cfg.settings.server.domain}"; 79 | }; 80 | })) 81 | 82 | (lib.mkIf (cfg.enable && cfg.configureNginx) { 83 | server = { 84 | protocol = "socket"; 85 | socket_gid = config.users.groups.nginx.gid; 86 | }; 87 | }) 88 | 89 | (lib.mkIf (cfg.enable && cfg.configurePostgres) { 90 | database = { 91 | host = "/run/postgresql"; 92 | type = "postgres"; 93 | user = "grafana"; 94 | }; 95 | }) 96 | 97 | (lib.mkIf (cfg.enable && cfg.oauth.enable) { 98 | "auth.generic_oauth" = let 99 | inherit (config.services.dex.settings) issuer; 100 | in { 101 | enabled = true; 102 | allow_assign_grafana_admin = true; # required for grafana-admins 103 | allow_sign_up = true; # otherwise no new users can be created 104 | api_url = "${issuer}/userinfo"; 105 | auth_url = "${issuer}/auth"; 106 | auto_login = true; # redirect automatically to the only oauth provider 107 | client_id = "grafana"; 108 | disable_login_form = true; # only allow OAuth 109 | icon = "signin"; 110 | name = config.services.portunus.webDomain; 111 | oauth_allow_insecure_email_lookup = true; # otherwise updating the mail in ldap will break login 112 | use_refresh_token = true; 113 | role_attribute_path = "contains(groups[*], '${cfg.oauth.adminGroup}') && 'Admin' || contains(groups[*], '${cfg.oauth.userGroup}') && 'Editor'" 114 | + lib.optionalString cfg.oauth.enableViewerRole "|| 'Viewer'"; 115 | role_attribute_strict = true; 116 | # https://dexidp.io/docs/custom-scopes-claims-clients/ 117 | scopes = "openid email groups profile offline_access"; 118 | token_url = "${issuer}/token"; 119 | }; 120 | }) 121 | ]; 122 | }; 123 | 124 | config.services.nginx = lib.mkIf (cfg.enable && cfg.configureNginx) { 125 | upstreams.grafana.servers."unix:${cfg.settings.server.socket}" = { }; 126 | virtualHosts = { 127 | "${cfg.settings.server.domain}".locations = { 128 | "/".proxyPass = "http://grafana"; 129 | "= /api/live/ws" = { 130 | proxyPass = "http://grafana"; 131 | proxyWebsockets = true; 132 | }; 133 | }; 134 | }; 135 | }; 136 | 137 | config.services.portunus = { 138 | dex = lib.mkIf cfg.oauth.enable { 139 | enable = true; 140 | oidcClients = [{ 141 | callbackURL = "https://${cfg.settings.server.domain}/login/generic_oauth"; 142 | id = "grafana"; 143 | }]; 144 | }; 145 | seedSettings.groups = lib.optional (cfg.oauth.adminGroup != null) { 146 | long_name = "Grafana Administrators"; 147 | name = cfg.oauth.adminGroup; 148 | permissions = { }; 149 | } ++ lib.optional (cfg.oauth.userGroup != null) { 150 | long_name = "Grafana Users"; 151 | name = cfg.oauth.userGroup; 152 | permissions = { }; 153 | }; 154 | }; 155 | 156 | config.services.postgresql = lib.mkIf cfg.configurePostgres { 157 | ensureDatabases = [ "grafana" ]; 158 | ensureUsers = [ { 159 | name = "grafana"; 160 | ensureDBOwnership = true; 161 | } ]; 162 | }; 163 | 164 | config.users.users = lib.mkIf (cfg.enable && cfg.configureNginx) { 165 | grafana.extraGroups = [ "nginx" ]; 166 | }; 167 | } 168 | -------------------------------------------------------------------------------- /modules/haproxy.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.haproxy; 5 | in 6 | { 7 | options = { 8 | services.haproxy = { 9 | compileWithAWSlc = libS.mkOpinionatedOption "compile nginx with aws-lc as crypto library"; 10 | }; 11 | }; 12 | 13 | config = lib.mkIf cfg.enable { 14 | services.haproxy = { 15 | package = lib.mkIf cfg.compileWithAWSlc (pkgs.haproxy.override { sslLibrary = "aws-lc"; }); 16 | }; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /modules/harmonia.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.services.harmonia; 5 | in 6 | { 7 | options = { 8 | services.harmonia = { 9 | configureNginx = lib.mkEnableOption "configure nginx for harmonia"; 10 | 11 | domain = lib.mkOption { 12 | type = lib.types.str; 13 | description = "Domain under which harmonia should be available."; 14 | }; 15 | 16 | port = lib.mkOption { 17 | type = lib.types.port; 18 | description = "Port on which harmonia should internally listen on."; 19 | }; 20 | 21 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 22 | }; 23 | }; 24 | 25 | config = lib.mkIf cfg.enable { 26 | services = { 27 | harmonia.settings = lib.mkIf cfg.recommendedDefaults { 28 | bind = "[::]:${toString cfg.port}"; 29 | priority = 50; # prefer cache.nixos.org 30 | }; 31 | 32 | nginx = lib.mkIf cfg.configureNginx { 33 | enable = true; 34 | virtualHosts."${cfg.domain}".locations."/" = { 35 | proxyPass = "http://127.0.0.1:${toString cfg.port}"; 36 | # harmonia serves already compressed content and we want to preserve Content-Length 37 | extraConfig = /* nginx */ '' 38 | proxy_buffering off; 39 | brotli off; 40 | gzip off; 41 | zstd off; 42 | ''; 43 | }; 44 | }; 45 | }; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /modules/hedgedoc.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.services.hedgedoc.ldap; 5 | inherit (config.security) ldap; 6 | in 7 | { 8 | options = { 9 | services.hedgedoc.ldap = { 10 | enable = lib.mkEnableOption '' 11 | login only via LDAP. 12 | Use `service.hedgedoc.environmentFile` in format `bindCredentials=password` to set the credentials used by the search user 13 | ''; 14 | 15 | userGroup = libS.ldap.mkUserGroupOption; 16 | }; 17 | }; 18 | 19 | config.services.hedgedoc.settings.ldap = lib.mkIf cfg.enable { 20 | url = ldap.serverURI; 21 | bindDn = ldap.bindDN; 22 | bindCredentials = "$bindCredentials"; 23 | searchBase = ldap.userBaseDN; 24 | searchFilter = ldap.searchFilterWithGroupFilter cfg.userGroup (ldap.userFilter "{{username}}"); 25 | tlsca = "/etc/ssl/certs/ca-certificates.crt"; 26 | useridField = ldap.userField; 27 | }; 28 | 29 | config.services.portunus.seedSettings.groups = lib.optional (cfg.userGroup != null) { 30 | long_name = "Hedgedoc Users"; 31 | name = cfg.userGroup; 32 | permissions = { }; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /modules/home-assistant-create-person-when-credentials-exist.diff: -------------------------------------------------------------------------------- 1 | --- a/homeassistant/auth/providers/command_line.py 2024-02-04 01:41:34.460181490 +0100 2 | +++ b/homeassistant/auth/providers/command_line.py 2024-02-04 01:46:55.952650748 +0100 3 | @@ -118,6 +118,13 @@ 4 | username = flow_result["username"].strip().casefold() 5 | 6 | users = await self.store.async_get_users() 7 | + hass = async_get_hass() 8 | + meta = self._user_meta.get(flow_result["username"], {}) 9 | + 10 | + pretty_name = meta.get("fullname") 11 | + if not pretty_name: 12 | + pretty_name = flow_result["username"] 13 | + 14 | for user in users: 15 | if user.name and user.name.strip().casefold() != username: 16 | continue 17 | @@ -127,28 +134,34 @@ 18 | 19 | for credential in await self.async_credentials(): 20 | if credential.data["username"] and credential.data["username"].strip().casefold() == username: 21 | + coll: person.PersonStorageCollection = hass.data[person.DOMAIN][1] 22 | + found = False 23 | + for pers in coll.async_items(): 24 | + if pers.get(person.ATTR_USER_ID) == user.id: 25 | + found = True 26 | + break 27 | + 28 | + if "person" in hass.config.components and not found: 29 | + await person.async_create_person(hass, pretty_name, user_id=user.id) 30 | + 31 | return credential 32 | 33 | cred = self.async_create_credentials({"username": username}) 34 | await self.store.async_link_user(user, cred) 35 | return cred 36 | 37 | - hass = async_get_hass() 38 | - meta = self._user_meta.get(flow_result["username"], {}) 39 | - 40 | provider = _async_get_hass_provider(hass) 41 | await provider.async_initialize() 42 | 43 | user = await hass.auth.async_create_user(flow_result["username"], group_ids=[meta.get("group")]) 44 | cred = await provider.async_get_or_create_credentials({"username": flow_result["username"]}) 45 | 46 | - pretty_name = meta.get("fullname") 47 | - if not pretty_name: 48 | - pretty_name = flow_result["username"] 49 | await provider.data.async_save() 50 | await hass.auth.async_link_user(user, cred) 51 | + 52 | if "person" in hass.config.components: 53 | await person.async_create_person(hass, pretty_name, user_id=user.id) 54 | + 55 | # Create new credentials. 56 | return cred 57 | 58 | -------------------------------------------------------------------------------- /modules/home-assistant-no-cloud.diff: -------------------------------------------------------------------------------- 1 | --- a/homeassistant/components/default_config/manifest.json 2023-10-22 01:46:48.596580412 +0200 2 | +++ b/homeassistant/components/default_config/manifest.json 2023-10-22 01:47:01.916784170 +0200 3 | @@ -7,7 +7,6 @@ 4 | "assist_pipeline", 5 | "automation", 6 | "bluetooth", 7 | - "cloud", 8 | "conversation", 9 | "counter", 10 | "dhcp", 11 | -------------------------------------------------------------------------------- /modules/home-assistant.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.home-assistant; 5 | inherit (config.security) ldap; 6 | in 7 | { 8 | options = { 9 | services.home-assistant = { 10 | configurePostgres = lib.mkOption { 11 | type = lib.types.bool; 12 | default = false; 13 | example = true; 14 | description = "Whether to configure and create a local PostgreSQL database."; 15 | }; 16 | 17 | ldap = { 18 | enable = lib.mkEnableOption ''login only via LDAP 19 | 20 | ::: {.note} 21 | Only enable this after completing the onboarding! 22 | ::: 23 | ''; 24 | 25 | userGroup = libS.ldap.mkUserGroupOption; 26 | adminGroup = lib.mkOption { 27 | type = with lib.types; nullOr str; 28 | default = null; 29 | example = "home-assistant-admins"; 30 | description = "Name of the ldap group that grants admin access in Home-Assistant."; 31 | }; 32 | }; 33 | 34 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 35 | }; 36 | }; 37 | 38 | config.nixpkgs.overlays = lib.mkIf cfg.enable [ 39 | (final: prev: { 40 | home-assistant = (prev.home-assistant.override (lib.optionalAttrs cfg.recommendedDefaults { 41 | extraPackages = ps: with ps; [ 42 | pyqrcode # for TOTP qrcode 43 | ]; 44 | })).overrideAttrs ({ patches ? [ ], ... }: { 45 | patches = patches ++ lib.optionals cfg.recommendedDefaults [ 46 | ./home-assistant-no-cloud.diff 47 | ] ++ lib.optionals cfg.ldap.enable [ 48 | # expand command_line authentication provider 49 | (final.fetchpatch { 50 | url = "https://github.com/home-assistant/core/pull/107419.diff"; 51 | hash = "sha256-e477JfbvwVVHMLJ3XMAyK6lcnZV6QCK6r/Bwihl+iUs="; 52 | }) 53 | ./home-assistant-create-person-when-credentials-exist.diff 54 | ]; 55 | 56 | doInstallCheck = false; 57 | }); 58 | }) 59 | ]; 60 | 61 | config.services.home-assistant = lib.mkMerge [ 62 | (lib.mkIf (cfg.enable && cfg.recommendedDefaults) { 63 | config = { 64 | # load imperative config done via the web ui 65 | # https://wiki.nixos.org/wiki/Home_Assistant#Automations,_Scenes,_and_Scripts_from_the_UI 66 | "automation ui" = "!include automations.yaml"; 67 | "scene ui" = "!include scenes.yaml"; 68 | "script ui" = "!include scripts.yaml"; 69 | 70 | default_config = { }; # yes, this is required... 71 | homeassistant = { 72 | # required for https://github.com/home-assistant/core/pull/107419 to allow new users 73 | auth_providers = [ 74 | { type = "homeassistant"; } 75 | ]; 76 | temperature_unit = "C"; 77 | time_zone = config.time.timeZone; 78 | unit_system = "metric"; 79 | }; 80 | 81 | # https://www.home-assistant.io/integrations/recorder/#common-filtering-examples 82 | recorder = { 83 | exclude = { 84 | domains = [ "automation" "update" ]; 85 | entity_globs = [ "sensor.sun*" "weather.*" ]; 86 | entities = [ "sensor.date" "sensor.last_boot" "sun.sun" ]; 87 | event_types = [ "call_service" ]; 88 | }; 89 | }; 90 | 91 | # see https://github.com/zigpy/zigpy/pull/1340 92 | zha.zigpy_config.ota.z2m_remote_index = "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index.json"; 93 | }; 94 | }) 95 | 96 | (lib.mkIf (cfg.enable && cfg.ldap.enable) { 97 | config.homeassistant.auth_providers = [{ 98 | type = "command_line"; 99 | # the script is not inheriting PATH from home-assistant 100 | command = pkgs.resholve.mkDerivation { 101 | pname = "ldap-auth-sh"; 102 | version = "unstable-2019-02-23"; 103 | 104 | src = pkgs.fetchFromGitHub { 105 | owner = "bob1de"; 106 | repo = "ldap-auth-sh"; 107 | rev = "819f9233116e68b5af5a5f45167bcbb4ed412ed4"; 108 | hash = "sha256-+QjRP5SKUojaCv3lZX2Kv3wkaNvpWFd97phwsRlhroY="; 109 | }; 110 | 111 | installPhase = '' 112 | install -Dm755 ldap-auth.sh -t $out/bin 113 | ''; 114 | 115 | solutions.default = { 116 | fake.external = [ "on_auth_failure" "on_auth_success" ]; 117 | inputs = with pkgs; [ coreutils curl gnugrep gnused openldap ]; 118 | interpreter = lib.getExe pkgs.bash; 119 | keep."source:$CONFIG_FILE" = true; 120 | scripts = [ "bin/ldap-auth.sh" ]; 121 | }; 122 | }+ "/bin/ldap-auth.sh"; 123 | args = [ 124 | # https://github.com/bob1de/ldap-auth-sh/blob/master/examples/home-assistant.cfg 125 | (pkgs.writeText "config.cfg" /* shell */ '' 126 | ATTRS="${ldap.userField} ${ldap.roleField} isMemberOf" 127 | CLIENT="ldapsearch" 128 | DEBUG=0 129 | FILTER="${ldap.groupFilter cfg.ldap.userGroup}" 130 | NAME_ATTR="${ldap.userField}" 131 | SCOPE="base" 132 | SERVER="${ldap.serverURI}" 133 | USERDN="uid=$(ldap_dn_escape "$username"),${ldap.userBaseDN}" 134 | BASEDN="$USERDN" 135 | 136 | on_auth_success() { 137 | # print the meta entries for use in HA 138 | if [ ! -z "$NAME_ATTR" ]; then 139 | name=$(echo "$output" | ${lib.getExe pkgs.gnused} -nr "s/^\s*${ldap.userField}:\s*(.+)\s*\$/\1/Ip") 140 | [ -z "$name" ] || echo "$name = $name" 141 | fullname=$(echo "$output" | ${lib.getExe pkgs.gnused} -nr "s/^\s*${ldap.roleField}:\s*(.+)\s*\$/\1/Ip") 142 | [ -z "$fullname" ] || echo "fullname = $fullname" 143 | ${lib.optionalString (cfg.ldap.adminGroup != null) /* bash */ '' 144 | group=$(echo "$output" | ${lib.getExe pkgs.gnused} -nr "s/^\s*isMemberOf: cn=${cfg.ldap.adminGroup}\s*(.+)\s*\$/\1/Ip") 145 | [ -z "$group" ] && echo "group = system-users" || echo "group = system-admin" 146 | ''} 147 | fi 148 | } 149 | 150 | # to receive less confusing errors 151 | on_auth_failure() { false; } 152 | '') 153 | ]; 154 | meta = true; 155 | }]; 156 | }) 157 | 158 | (lib.mkIf cfg.configurePostgres { 159 | config.recorder.db_url = "postgresql://@/hass"; 160 | }) 161 | ]; 162 | 163 | config.services.portunus.seedSettings.groups = lib.optional (cfg.ldap.userGroup != null) { 164 | long_name = "Home-Assistant Users"; 165 | name = cfg.ldap.userGroup; 166 | permissions = { }; 167 | } ++ lib.optional (cfg.ldap.adminGroup != null) { 168 | long_name = "Home-Assistant Administrators"; 169 | name = cfg.ldap.adminGroup; 170 | permissions = { }; 171 | }; 172 | 173 | config.services.postgresql = lib.mkIf cfg.configurePostgres { 174 | ensureDatabases = [ "hass" ]; 175 | ensureUsers = [ { 176 | name = "hass"; 177 | ensureDBOwnership = true; 178 | } ]; 179 | }; 180 | 181 | config.systemd.tmpfiles.rules = lib.mkIf (cfg.enable && cfg.recommendedDefaults) [ 182 | "f ${cfg.configDir}/automations.yaml 0444 hass hass" 183 | "f ${cfg.configDir}/scenes.yaml 0444 hass hass" 184 | "f ${cfg.configDir}/scripts.yaml 0444 hass hass" 185 | ]; 186 | } 187 | -------------------------------------------------------------------------------- /modules/hound.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | let 4 | cfg = config.services.hound; 5 | in 6 | { 7 | options = { 8 | services.hound = { 9 | repos = lib.mkOption { 10 | type = with lib.types; listOf str; 11 | default = []; 12 | example = [ "https://github.com/NuschtOS/nixos-modules.git" ]; 13 | description = '' 14 | A list of repos which should be fetched from their default branch. The display name is derived using builtins.baseNameOf and .git is stripped 15 | ''; 16 | }; 17 | }; 18 | }; 19 | 20 | config = lib.mkIf cfg.enable { 21 | services.hound.settings = lib.mkIf (cfg.repos != [ ]) { 22 | vcs-config.git.detect-ref = true; 23 | repos = lib.listToAttrs (map (url: lib.nameValuePair 24 | (lib.removeSuffix ".git" (builtins.baseNameOf url)) 25 | { inherit url; } 26 | ) cfg.repos); 27 | }; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /modules/hydra.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.services.hydra; 5 | cfgl = cfg.ldap; 6 | inherit (config.security) ldap; 7 | in 8 | { 9 | options = { 10 | services.hydra = { 11 | configurePostgres = lib.mkOption { 12 | type = lib.types.bool; 13 | default = false; 14 | example = true; 15 | description = "Whether to configure and create a local PostgreSQL database."; 16 | }; 17 | 18 | ldap = { 19 | enable = lib.mkEnableOption '' 20 | login only via LDAP. 21 | The bind user password must be placed at `/var/lib/hydra/ldap-password.conf` in the format `bindpw = "PASSWORD" 22 | It is recommended to use a password without special characters because the perl config parser has weird escaping rule 23 | like that comment characters `#` must be escape with backslash 24 | ''; 25 | 26 | roleMappings = lib.mkOption { 27 | type = with lib.types; listOf (attrsOf str); 28 | example = [{ hydra-admins = "admins"; }]; 29 | default = [ ]; 30 | description = "Map LDAP groups to hydra permissions. See upstream doc, especially role_mapping."; 31 | }; 32 | 33 | userGroup = libS.ldap.mkUserGroupOption; 34 | }; 35 | }; 36 | }; 37 | 38 | config.services.hydra.extraConfig = lib.mkIf cfgl.enable /* xml */ '' 39 | # https://hydra.nixos.org/build/196107287/download/1/hydra/configuration.html#using-ldap-as-authentication-backend-optional 40 | 41 | 42 | 43 | class = Password 44 | password_field = password 45 | password_type = self_check 46 | 47 | 48 | class = LDAP 49 | ldap_server = "${ldap.domainName}" 50 | 51 | scheme = ldaps 52 | timeout = 10 53 | 54 | binddn = "${ldap.bindDN}" 55 | include ldap-password.conf 56 | start_tls = 0 57 | 58 | ciphers = TLS_AES_256_GCM_SHA384 59 | sslversion = tlsv1_3 60 | 61 | user_basedn = "${ldap.userBaseDN}" 62 | user_filter = "${ldap.searchFilterWithGroupFilter cfgl.userGroup (ldap.userFilter "%s")}" 63 | user_scope = one 64 | user_field = ${ldap.userField} 65 | 66 | deref = always 67 | 68 | # Important for role mappings to work: 69 | use_roles = 1 70 | role_basedn = "${ldap.roleBaseDN}" 71 | role_filter = "${ldap.roleFilter}" 72 | role_scope = one 73 | role_field = ${ldap.roleField} 74 | role_value = ${ldap.roleValue} 75 | 76 | deref = always 77 | 78 | 79 | 80 | 81 | # Make all users in the hydra-admin group Hydra admins 82 | # hydra-admins = admin 83 | # Allow all users in the dev group to restart jobs and cancel builds 84 | # dev = restart-jobs 85 | # dev = cancel-build 86 | ${lib.concatStringsSep "\n" (lib.concatMap (lib.mapAttrsToList (name: value: "${name} = ${value}")) cfgl.roleMappings)} 87 | 88 | 89 | ''; 90 | 91 | config.services.portunus.seedSettings.groups = [ 92 | (lib.mkIf (cfgl.userGroup != null) { 93 | long_name = "Hydra Users"; 94 | name = cfgl.userGroup; 95 | permissions = { }; 96 | }) 97 | ] ++ lib.flatten (map lib.attrValues (map 98 | (lib.mapAttrs (ldapGroup: _: { 99 | long_name = "Hydra Role ${ldapGroup}"; 100 | name = ldapGroup; 101 | permissions = { }; 102 | })) 103 | cfgl.roleMappings)); 104 | 105 | config.services.postgresql = lib.mkIf cfg.configurePostgres { 106 | ensureDatabases = [ "hydra" ]; 107 | ensureUsers = [ { 108 | name = "hydra"; 109 | ensureDBOwnership = true; 110 | } ]; 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /modules/initrd.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.boot.initrd.network.ssh; 5 | initrdEd25519Key = "/etc/ssh/initrd/ssh_host_ed25519_key"; 6 | initrdRsaKey = "/etc/ssh/initrd/ssh_host_rsa_key"; 7 | in 8 | { 9 | options = { 10 | boot.initrd.network.ssh = { 11 | configureHostKeys = lib.mkEnableOption "configure before generate openssh host keys for the initrd"; 12 | generateHostKeys = lib.mkEnableOption "generate openssh host keys for the initrd. This must be enabled before they can be configured"; 13 | regenerateWeakRSAHostKey = libS.mkOpinionatedOption "regenerate weak (less than 4096 bits) RSA host keys" // { 14 | default = config.services.openssh.regenerateWeakRSAHostKey; 15 | }; 16 | }; 17 | }; 18 | 19 | config = lib.mkIf cfg.enable { 20 | boot.initrd.network.ssh.hostKeys = lib.mkIf cfg.configureHostKeys [ 21 | initrdEd25519Key 22 | initrdRsaKey 23 | ]; 24 | 25 | system.activationScripts.generateInitrdOpensshHostKeys = let 26 | sshKeygen = lib.getExe' config.programs.ssh.package "ssh-keygen"; 27 | in lib.optionalString cfg.generateHostKeys /* bash */ '' 28 | if [[ ! -e ${initrdEd25519Key} || ! -e ${initrdRsaKey} ]]; then 29 | echo "Generating OpenSSH initrd hostkeys..." 30 | mkdir -m700 -p /etc/ssh/initrd/ 31 | ${sshKeygen} -t ed25519 -N "" -f ${initrdEd25519Key} 32 | ${sshKeygen} -t rsa -b 4096 -N "" -f ${initrdRsaKey} 33 | fi 34 | '' + lib.optionalString cfg.regenerateWeakRSAHostKey /* bash */ '' 35 | if [[ -e ${initrdRsaKey} && $(${sshKeygen} -l -f ${initrdRsaKey} | ${lib.getExe pkgs.gawk} '{print $1}') != 4096 ]]; then 36 | echo "Regenerating OpenSSH initrd RSA hostkey which had less than 4096 bits..." 37 | rm -f ${initrdRsaKey} ${initrdRsaKey}.pub 38 | ${sshKeygen} -t rsa -b 4096 -N "" -f ${initrdRsaKey} 39 | fi 40 | ''; 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /modules/intel.nix: -------------------------------------------------------------------------------- 1 | { lib, ... }: 2 | 3 | { 4 | imports = [ 5 | # TODO: drop with nixos 25.05 6 | (lib.mkRemovedOptionModule ["hardware" "intelGPU"] "Please use hardware.intelgpu from nixos-hardware instead.") 7 | ]; 8 | } 9 | -------------------------------------------------------------------------------- /modules/ldap.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | let 4 | cfg = config.security.ldap; 5 | in 6 | { 7 | options.security.ldap = { 8 | bindDN = lib.mkOption { 9 | type = with lib.types; nullOr str; 10 | example = "uid=search"; 11 | default = if cfg.searchUID != null then "uid=${cfg.searchUID}" else null; 12 | apply = s: s + "," + cfg.userBaseDN; 13 | description = '' 14 | The DN of the service user used by services. 15 | The user base dn will be automatically appended. 16 | ''; 17 | }; 18 | 19 | domainComponent = lib.mkOption { 20 | type = with lib.types; listOf str; 21 | example = [ "example" "com" ]; 22 | apply = dc: lib.removeSuffix "," (lib.concatMapStrings (x: "dc=${x},") dc); 23 | description = '' 24 | Domain component(s) (dc) represented as a list of strings. 25 | 26 | Each entry will be prefixed with `dc=` and all are concatinated with `,`, except the last one. 27 | The example would be concatinated to `dc=example,dc=com` 28 | ''; 29 | }; 30 | 31 | domainName = lib.mkOption { 32 | type = lib.types.str; 33 | example = "auth.internal.example.com"; 34 | description = "The domain name to connect to the ldap server's ldaps port."; 35 | }; 36 | 37 | webDomainName = lib.mkOption { 38 | type = lib.types.str; 39 | example = "auth.example.com"; 40 | description = "The domain name to connect to, to visit the ldap server web interface and to which to issue cookies to."; 41 | }; 42 | 43 | givenNameField = lib.mkOption { 44 | type = lib.types.str; 45 | example = "givenName"; 46 | description = "The attribute of the user object where to find its given name."; 47 | }; 48 | 49 | groupFilter = lib.mkOption { 50 | type = with lib.types; functionTo str; 51 | example = lib.literalExpression ''group: "(&(objectclass=person)(isMemberOf=cn=''${group},''${config.security.ldap.roleBaseDN}"''; 52 | description = "A function that returns a group filter that matches the first argument against the names of the groups the user is part of."; 53 | }; 54 | 55 | mailField = lib.mkOption { 56 | type = lib.types.str; 57 | example = "mail"; 58 | description = "The attribute of the user object where to find its email."; 59 | }; 60 | 61 | port = lib.mkOption { 62 | type = lib.types.port; 63 | example = "636"; 64 | description = "The port the ldap server listens on. Usually this is 389 for ldap and 636 for ldaps."; 65 | }; 66 | 67 | roleBaseDN = lib.mkOption { 68 | type = lib.types.str; 69 | example = "ou=groups"; 70 | apply = s: s + "," + cfg.domainComponent; 71 | description = '' 72 | The directory path where applications should search for users. 73 | Domain component will be automatically appended. 74 | ''; 75 | }; 76 | 77 | roleField = lib.mkOption { 78 | type = lib.types.str; 79 | example = "cn"; 80 | description = "The attribute where the user account is listed in a group."; 81 | }; 82 | 83 | roleFilter = lib.mkOption { 84 | type = lib.types.str; 85 | example = "(&(objectclass=groupOfNames)(member=%s))"; 86 | description = "Filter to get the groups of an user object."; 87 | }; 88 | 89 | roleValue = lib.mkOption { 90 | type = lib.types.str; 91 | example = "dn"; 92 | description = "The attribute of the user object where to find its distinguished name."; 93 | }; 94 | 95 | searchUID = lib.mkOption { 96 | type = with lib.types; nullOr str; 97 | default = null; 98 | example = "search"; 99 | description = "The uid of the service user used by services, often referred as search user."; 100 | }; 101 | 102 | searchFilterWithGroupFilter = lib.mkOption { 103 | type = with lib.types; functionTo (functionTo str); 104 | example = lib.literalExpression ''userFilterGroup: userFilter: if (userFilterGroup != null) then "(&''${config.security.ldap.groupFilter userFilterGroup})" else userFilter''; 105 | description = '' 106 | A function that returns a search filter that may include a group filter. 107 | The first argument may be the group that is filtered upon or null. 108 | If set to null no additional filtering is done. If set the supplied filter is combined with the user filter. 109 | The second argument must be the user filter including the applications placeholders or ideally the userFilter option. 110 | ''; 111 | }; 112 | 113 | serverURI = lib.mkOption { 114 | type = lib.types.str; 115 | default = "ldaps://${cfg.domainName}:${toString cfg.port}"; 116 | defaultText = "ldaps://$''{config.security.ldap.domainName}:$''{config.security.ldap.port}"; 117 | example = "ldap://$''{config.security.ldap.domainName}"; 118 | description = "The ldap server URI"; 119 | }; 120 | 121 | sshPublicKeyField = lib.mkOption { 122 | type = lib.types.str; 123 | example = "sshPublicKey"; 124 | description = "The attribute of the user object where to find its ssh public key."; 125 | }; 126 | 127 | surnameField = lib.mkOption { 128 | type = lib.types.str; 129 | example = "sn"; 130 | description = "The attribute of the user object where to find its surname."; 131 | }; 132 | 133 | userBaseDN = lib.mkOption { 134 | type = lib.types.str; 135 | example = "ou=users"; 136 | apply = s: s + "," + cfg.domainComponent; 137 | description = '' 138 | The directory path where applications should search for users. 139 | Domain component will be automatically appended. 140 | ''; 141 | }; 142 | 143 | userField = lib.mkOption { 144 | type = lib.types.str; 145 | example = "uid"; 146 | description = "The attribute of the user object where to find its username."; 147 | }; 148 | 149 | userFilter = lib.mkOption { 150 | type = with lib.types; functionTo str; 151 | example = ''param: "(&(objectclass=person)(|(uid=''${param})(mail=''${param})))"''; 152 | description = "A function that returns a user search filter that uses the first argument as the placeholder."; 153 | }; 154 | }; 155 | } 156 | -------------------------------------------------------------------------------- /modules/mailman.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | let 4 | cfg = config.services.mailman; 5 | in 6 | { 7 | options = { 8 | services.mailman = { 9 | enablePostgres = lib.mkEnableOption "" // { description = "Whether to configure postgres as a database backend."; }; 10 | 11 | openidConnect = { 12 | enable = lib.mkEnableOption "login only via OpenID Connect"; 13 | 14 | clientSecretFile = lib.mkOption { 15 | type = lib.types.str; 16 | description = "Path of the file containing the client id"; 17 | }; 18 | }; 19 | }; 20 | }; 21 | 22 | config.environment.etc = lib.mkIf (cfg.enable && cfg.openidConnect.enable) { 23 | "mailman3/settings.py".text = lib.mkAfter /* python */ '' 24 | INSTALLED_APPS.append('allauth.socialaccount.providers.openid_connect') 25 | 26 | with open('${cfg.openidConnect.clientSecretFile}') as f: 27 | SOCIALACCOUNT_PROVIDERS = { 28 | "openid_connect": { 29 | "APPS": [{ 30 | "provider_id": "dex", 31 | "name": "${config.services.portunus.webDomain}", 32 | "client_id": "mailman", 33 | "secret": f.read(), 34 | "settings": { 35 | "server_url": "${config.services.dex.settings.issuer}", 36 | }, 37 | }], 38 | } 39 | } 40 | ''; 41 | }; 42 | 43 | config.services.mailman = lib.mkIf (cfg.enable && cfg.enablePostgres) { 44 | settings.database = { 45 | class = "mailman.database.postgresql.PostgreSQLDatabase"; 46 | url = "postgresql://mailman@/mailman?host=/run/postgresql"; 47 | }; 48 | webSettings = { 49 | DATABASES.default = { 50 | ENGINE = "django.db.backends.postgresql"; 51 | NAME = "mailman-web"; 52 | USER = "mailman-web"; 53 | }; 54 | }; 55 | }; 56 | 57 | config.services.postgresql = lib.mkIf (cfg.enable && cfg.enablePostgres) { 58 | ensureDatabases = [ "mailman" "mailman-web" ]; 59 | ensureUsers = [ { 60 | name = "mailman"; 61 | ensureDBOwnership = true; 62 | } { 63 | name = "mailman-web"; 64 | ensureDBOwnership = true; 65 | } ]; 66 | }; 67 | 68 | config.services.portunus.dex = lib.mkIf cfg.openidConnect.enable { 69 | enable = true; 70 | oidcClients = [{ 71 | callbackURL = "https://${lib.elemAt cfg.webHosts 0}/accounts/oidc/dex/login/callback/"; 72 | id = "mailman"; 73 | }]; 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /modules/mastodon-bird-ui.patch: -------------------------------------------------------------------------------- 1 | --- a/config/locales/de.yml 1970-01-01 01:00:01.000000000 +0100 2 | +++ b/config/locales/de.yml 2025-03-04 23:10:30.555066004 +0100 3 | @@ -1804,6 +1804,9 @@ 4 | themes: 5 | contrast: Mastodon (Hoher Kontrast) 6 | default: Mastodon (Dunkel) 7 | + mastodon-bird-ui-contrast: Mastodon Bird UI (Hoher Kontrast) 8 | + mastodon-bird-ui-dark: Mastodon Bird UI (Dunkel) 9 | + mastodon-bird-ui-light: Mastodon Bird UI (Hell) 10 | mastodon-light: Mastodon (Hell) 11 | system: Automatisch (mit System synchronisieren) 12 | time: 13 | --- a/config/locales/en.yml 1970-01-01 01:00:01.000000000 +0100 14 | +++ b/config/locales/en.yml 2025-03-04 23:11:25.390474198 +0100 15 | @@ -1805,6 +1805,9 @@ 16 | themes: 17 | contrast: Mastodon (High contrast) 18 | default: Mastodon (Dark) 19 | + mastodon-bird-ui-contrast: Mastodon Bird UI (High contrast) 20 | + mastodon-bird-ui-dark: Mastodon Bird UI (Dark) 21 | + mastodon-bird-ui-light: Mastodon Bird UI (Light) 22 | mastodon-light: Mastodon (Light) 23 | system: Automatic (use system theme) 24 | time: 25 | --- a/config/themes.yml 1970-01-01 01:00:01.000000000 +0100 26 | +++ b/config/themes.yml 2025-03-04 23:12:24.998956621 +0100 27 | @@ -1,3 +1,6 @@ 28 | default: styles/application.scss 29 | contrast: styles/contrast.scss 30 | mastodon-light: styles/mastodon-light.scss 31 | +mastodon-bird-ui-dark: styles/mastodon-bird-ui-dark.scss 32 | +mastodon-bird-ui-contrast: styles/mastodon-bird-ui-contrast.scss 33 | +mastodon-bird-ui-light: styles/mastodon-bird-ui-light.scss 34 | -------------------------------------------------------------------------------- /modules/mastodon.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.mastodon; 5 | cfgl = cfg.ldap; 6 | cfgo = cfg.oauth; 7 | inherit (config.security) ldap; 8 | in 9 | { 10 | options.services.mastodon = { 11 | enableBirdUITheme = lib.mkEnableOption "Bird UI Theme"; 12 | 13 | extraSecretsEnv = lib.mkOption { 14 | type = with lib.types; nullOr path; 15 | default = null; 16 | description = '' 17 | Extra envs to write into `/var/lib/mastodon/.secrets_env`. 18 | 19 | The format is: 20 | 21 | ``` 22 | OIDC_CLIENT_SECRET="$(cat /path/to/clientSecret)" 23 | ``` 24 | ''; 25 | }; 26 | 27 | ldap = { 28 | enable = lib.mkEnableOption "login via LDAP"; 29 | 30 | userGroup = libS.ldap.mkUserGroupOption; 31 | }; 32 | 33 | oauth = { 34 | enable = lib.mkEnableOption '' 35 | login via OAuth2. 36 | This requires providing OIDC_CLIENT_SECRET via services.mastodon.extraSecretsEnv 37 | ''; 38 | 39 | clientId = lib.mkOption { 40 | type = lib.types.str; 41 | description = "OAuth2 client id"; 42 | }; 43 | }; 44 | }; 45 | 46 | config = { 47 | nixpkgs.overlays = lib.mkIf cfg.enableBirdUITheme [ 48 | (final: prev: { 49 | mastodon = (prev.mastodon.override { 50 | patches = [ 51 | # redone based on https://codeberg.org/rheinneckar.social/nixos-config/src/branch/main/patches/mastodon-bird-ui.patch 52 | ./mastodon-bird-ui.patch 53 | ]; 54 | }).overrideAttrs (oldAttrs: let 55 | src = pkgs.applyPatches { 56 | src = final.fetchFromGitHub { 57 | owner = "ronilaukkarinen"; 58 | repo = "mastodon-bird-ui"; 59 | tag = "2.1.1"; 60 | hash = "sha256-WEw9wE+iBCLDDTZjFoDJ3EwKTY92+LyJyDqCIoVXhzk="; 61 | }; 62 | 63 | # based on: 64 | # https://github.com/ronilaukkarinen/mastodon-bird-ui#make-mastodon-bird-ui-as-optional-by-integrating-it-as-site-theme-in-settings-for-all-users 65 | postPatch = '' 66 | substituteInPlace layout-single-column.css layout-multiple-columns.css \ 67 | --replace-fail theme-contrast theme-mastodon-bird-ui-contrast \ 68 | --replace-fail theme-mastodon-light theme-mastodon-bird-ui-light 69 | 70 | mkdir mastodon-bird-ui 71 | mv layout-single-column.css mastodon-bird-ui/layout-single-column.scss 72 | mv layout-multiple-columns.css mastodon-bird-ui/layout-multiple-columns.scss 73 | 74 | echo -e "@import 'contrast/variables'; 75 | @import 'application'; 76 | @import 'contrast/diff'; 77 | @import 'mastodon-bird-ui/layout-single-column.scss'; 78 | @import 'mastodon-bird-ui/layout-multiple-columns.scss';" > mastodon-bird-ui-contrast.scss 79 | echo -e "@import 'mastodon-light/variables'; 80 | @import 'application'; 81 | @import 'mastodon-light/diff'; 82 | @import 'mastodon-bird-ui/layout-single-column.scss'; 83 | @import 'mastodon-bird-ui/layout-multiple-columns.scss';" > mastodon-bird-ui-light.scss 84 | echo -e "@import 'application'; 85 | @import 'mastodon-bird-ui/layout-single-column.scss'; 86 | @import 'mastodon-bird-ui/layout-multiple-columns.scss';" > mastodon-bird-ui-dark.scss 87 | ''; 88 | }; 89 | in { 90 | mastodonModules = oldAttrs.mastodonModules.overrideAttrs (oldAttrs: { 91 | pname = "mastodon-birdui-theme"; 92 | 93 | postPatch = oldAttrs.postPatch or "" + '' 94 | cp -r ${src}/*.scss ${src}/mastodon-bird-ui/ app/javascript/styles/ 95 | ''; 96 | }); 97 | }); 98 | }) 99 | ]; 100 | 101 | services.mastodon.extraConfig = lib.mkMerge [ 102 | (lib.mkIf cfgl.enable { 103 | LDAP_ENABLED = "true"; 104 | LDAP_BASE = ldap.userBaseDN; 105 | LDAP_BIND_DN = ldap.bindDN; 106 | LDAP_HOST = ldap.domainName; 107 | LDAP_METHOD = "simple_tls"; 108 | LDAP_PORT = toString ldap.port; 109 | LDAP_UID = ldap.userField; 110 | # convert .,- (space) in LDAP usernames to underscore, otherwise those users cannot log in 111 | LDAP_UID_CONVERSION_ENABLED = "true"; 112 | LDAP_SEARCH_FILTER = ldap.searchFilterWithGroupFilter cfgl.userGroup "(|(%{uid}=%{email})(%{mail}=%{email}))"; 113 | }) 114 | 115 | (lib.mkIf cfgo.enable { 116 | OIDC_ENABLED = "true"; 117 | OIDC_DISPLAY_NAME = "auth.c3d2.de"; 118 | OIDC_DISCOVERY = "true"; 119 | OIDC_ISSUER = config.services.dex.settings.issuer; 120 | OIDC_AUTH_ENDPOINT = config.services.dex.discoveryEndpoint; 121 | OIDC_SCOPE = "openid,profile,email"; 122 | OIDC_UID_FIELD = "preferred_username"; 123 | OIDC_CLIENT_ID = cfgo.clientId; 124 | OIDC_REDIRECT_URI = "https://${cfg.localDomain}/auth/auth/openid_connect/callback"; 125 | OIDC_SECURITY_ASSUME_EMAIL_IS_VERIFIED = "true"; 126 | }) 127 | ]; 128 | 129 | systemd.services = lib.mkIf (cfg.extraSecretsEnv != null) { 130 | mastodon-init-dirs.script = lib.mkAfter '' 131 | cat ${cfg.extraSecretsEnv} >> /var/lib/mastodon/.secrets_env 132 | ''; 133 | }; 134 | }; 135 | 136 | config.services.portunus.seedSettings.groups = lib.optional (cfgl.userGroup != null) { 137 | long_name = "Mastodon Users"; 138 | name = cfgl.userGroup; 139 | permissions = { }; 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /modules/matrix.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, options, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.matrix-synapse; 5 | cfga = cfg.synapse-admin; 6 | cfge = cfg.element-web; 7 | cfgl = cfg.ldap; 8 | inherit (config.security) ldap; 9 | in 10 | { 11 | options = { 12 | services.matrix-synapse = { 13 | addAdditionalOembedProvider = libS.mkOpinionatedOption "add additional oembed providers from oembed.com"; 14 | 15 | configurePostgres = lib.mkOption { 16 | type = lib.types.bool; 17 | default = false; 18 | example = true; 19 | description = "Whether to configure and create a local PostgreSQL database."; 20 | }; 21 | 22 | domain = lib.mkOption { 23 | type = lib.types.str; 24 | example = "matrix.example.com"; 25 | description = "The domain that matrix-synapse will use."; 26 | }; 27 | 28 | element-web = { 29 | enable = lib.mkEnableOption "" // { description = "Whether to configure the element-web client under Matrix' domain."; }; 30 | 31 | domain = lib.mkOption { 32 | type = lib.types.str; 33 | example = "element.example.com"; 34 | description = "The domain that element-web will use."; 35 | }; 36 | 37 | package = lib.mkPackageOption pkgs "element-web" { }; 38 | 39 | enableConfigFeatures = libS.mkOpinionatedOption "enable most features available via config.json"; 40 | }; 41 | 42 | listenOnSocket = libS.mkOpinionatedOption "listen on a unix socket instead of a port"; 43 | 44 | ldap = { 45 | enable = lib.mkEnableOption "login via ldap"; 46 | 47 | userGroup = libS.ldap.mkUserGroupOption; 48 | 49 | searchUserPasswordFile = lib.mkOption { 50 | type = lib.types.str; 51 | example = "/var/lib/secrets/search-user-password"; 52 | description = "Path to a file containing the password for the search/bind user."; 53 | }; 54 | }; 55 | 56 | synapse-admin = { 57 | enable = lib.mkEnableOption "" // { description = "Whether to configure synapse-admin to be served at the matrix servers domain under the /admin path."; }; 58 | 59 | package = lib.mkPackageOption pkgs "synapse-admin" { } // { 60 | # TODO: remove after 25.05 61 | default = pkgs.synapse-admin-etkecc or pkgs.synapse-admin; 62 | example = "pkgs.synapse-admin-etkecc"; 63 | extraDescription = "If synapse-admin-etkecc exists, that is the default, otherwise synapse-admin."; 64 | }; 65 | }; 66 | 67 | recommendedDefaults = libS.mkOpinionatedOption "set recommended and secure default settings"; 68 | }; 69 | }; 70 | 71 | imports = [ 72 | (lib.mkRenamedOptionModule [ "services" "matrix-synapse" "ldap" "bindPasswordFile" ] [ "services" "matrix-synapse" "ldap" "searchUserPasswordFile" ]) 73 | (lib.mkRemovedOptionModule [ "services" "matrix-synapse" "matrix-sliding-sync" ] "matrix-sliding-sync has been removed as matrix-synapse 114.0 and later covers its functionality") 74 | ]; 75 | 76 | config = lib.mkIf cfg.enable { 77 | assertions = [ { 78 | assertion = cfg.listenOnSocket -> config.services.nginx.enable; 79 | message = "Enabling services.matrix-synapse.listenOnSocket requires enabling services.nginx.enable"; 80 | } ]; 81 | 82 | environment.etc."matrix-synapse/config.yaml".source = cfg.configFile; 83 | 84 | services.matrix-synapse = lib.mkMerge [ 85 | { 86 | enableRegistrationScript = lib.mkIf cfg.listenOnSocket false; 87 | 88 | settings.listeners = lib.mkIf cfg.listenOnSocket (lib.mkForce [ 89 | ((lib.head (lib.head (lib.head options.services.matrix-synapse.settings.type.getSubModules).imports).options.listeners.default) // { 90 | bind_addresses = null; 91 | path = "/run/matrix-synapse/matrix-synapse.sock"; 92 | port = null; 93 | tls = null; 94 | }) 95 | ]); 96 | 97 | settings.oembed.additional_providers = lib.mkIf cfg.addAdditionalOembedProvider [ 98 | ( 99 | let 100 | providers = pkgs.fetchurl { 101 | url = "https://oembed.com/providers.json?2024-12-13"; 102 | hash = "sha256-CL2d/DRukPZCHOkAWz3dCD1551KLcdIJBG1XiaWXYc8="; 103 | }; 104 | in 105 | pkgs.runCommand "providers.json" 106 | { 107 | nativeBuildInputs = with pkgs; [ jq ]; 108 | } '' 109 | # filter out entries that do not contain a schemes entry 110 | # Error in configuration at 'oembed.additional_providers...endpoints.': 'schemes' is a required property 111 | # and have none http protocols: Unsupported oEmbed scheme (spotify) for pattern: spotify:* 112 | jq '[ ..|objects| select(.endpoints[0]|has("schemes")) | .endpoints[0].schemes=([ .endpoints[0].schemes[]|select(.|contains("http")) ]) ]' ${providers} > $out 113 | '' 114 | ) 115 | ]; 116 | } 117 | 118 | (lib.mkIf cfg.recommendedDefaults { 119 | log = { 120 | loggers."synapse.http.matrixfederationclient".level = "ERROR"; 121 | root.level = "WARN"; 122 | }; 123 | settings = { 124 | federation_client_minimum_tls_version = "1.2"; 125 | public_baseurl = "https://${cfg.domain}"; 126 | suppress_key_server_warning = true; 127 | user_directory.prefer_local_users = true; 128 | }; 129 | withJemalloc = true; 130 | }) 131 | 132 | (lib.mkIf cfge.enable { 133 | settings = { 134 | email.client_base_url = cfg.settings.web_client_location; 135 | web_client_location = "https://${cfge.domain}"; 136 | }; 137 | }) 138 | 139 | (lib.mkIf cfgl.enable { 140 | plugins = with cfg.package.plugins; [ 141 | matrix-synapse-ldap3 142 | ]; 143 | 144 | settings.modules = [{ 145 | module = "ldap_auth_provider.LdapAuthProviderModule"; 146 | config = { 147 | enabled = true; 148 | mode = "search"; 149 | uri = ldap.serverURI; 150 | base = ldap.userBaseDN; 151 | attributes = { 152 | uid = ldap.userField; 153 | mail = ldap.mailField; 154 | name = ldap.givenNameField; 155 | }; 156 | bind_dn = ldap.bindDN; 157 | bind_password_file = cfgl.searchUserPasswordFile; 158 | tls_options.validate = true; 159 | } // lib.optionalAttrs (cfgl.userGroup != null) { 160 | filter = ldap.groupFilter cfgl.userGroup; 161 | }; 162 | }]; 163 | }) 164 | ]; 165 | 166 | services.nginx = { 167 | upstreams = lib.mkIf cfg.listenOnSocket { 168 | matrix-synapse.servers."unix:/run/matrix-synapse/matrix-synapse.sock" = { }; 169 | }; 170 | 171 | virtualHosts = lib.mkMerge [ 172 | (lib.mkIf cfga.enable { 173 | # see https://github.com/etkecc/synapse-admin/blob/main/docs/reverse-proxy.md#nginx 174 | "${cfg.domain}" = { 175 | forceSSL = lib.mkIf cfg.recommendedDefaults true; 176 | locations = { 177 | "= /admin".return = "307 /admin/"; 178 | "/admin/" = { 179 | alias = "${cfga.package}/"; 180 | priority = 500; 181 | tryFiles = "$uri $uri/ /index.html"; 182 | }; 183 | "~ ^/admin/.*\\.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff|woff2|ttf|eot|webp)$" = { 184 | priority = 400; 185 | root = cfga.package; 186 | extraConfig = /* nginx */ '' 187 | rewrite ^/admin/(.*)$ /$1 break; 188 | expires 30d; 189 | more_set_headers "Cache-Control: public"; 190 | ''; 191 | }; 192 | }; 193 | }; 194 | }) 195 | 196 | (lib.mkIf cfge.enable { 197 | "${cfge.domain}" = { 198 | forceSSL = lib.mkIf cfg.recommendedDefaults true; 199 | locations."/".root = (cfge.package.override { 200 | conf = lib.recursiveUpdate 201 | (lib.recursiveUpdate 202 | (let 203 | inherit (config.services.matrix-synapse.settings) public_baseurl server_name; 204 | in { 205 | default_server_config."m.homeserver" = { 206 | "base_url" = public_baseurl; 207 | "server_name" = server_name; 208 | }; 209 | default_theme = "dark"; 210 | room_directory.servers = [ server_name ]; 211 | }) 212 | (lib.optionalAttrs cfge.enableConfigFeatures { 213 | features = { 214 | # https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/settings/Settings.tsx 215 | # https://github.com/vector-im/element-web/blob/develop/docs/labs.md 216 | feature_ask_to_join = true; 217 | feature_bridge_state = true; 218 | feature_jump_to_date = true; 219 | feature_mjolnir = true; 220 | feature_notifications = true; 221 | feature_pinning = true; 222 | feature_report_to_moderators = true; 223 | }; 224 | show_labs_settings = true; 225 | }) 226 | ) 227 | (cfge.package.conf or { }); 228 | }).overrideAttrs ({ postInstall ? "", ... }: { 229 | # prevent 404 spam in nginx log 230 | postInstall = postInstall + '' 231 | ln -rs $out/config.json $out/config.${cfge.domain}.json 232 | ''; 233 | }); 234 | }; 235 | }) 236 | 237 | { 238 | "${cfg.domain}" = { 239 | forceSSL = lib.mkIf cfg.recommendedDefaults true; 240 | locations."/" = lib.mkIf cfg.listenOnSocket { 241 | proxyPass = "http://matrix-synapse"; 242 | }; 243 | }; 244 | } 245 | ]; 246 | }; 247 | 248 | services.postgresql = lib.mkIf cfg.configurePostgres { 249 | databases = [ "matrix-synapse" ]; # some parts of nixos-modules read this field to know all databases 250 | }; 251 | 252 | services.portunus.seedSettings.groups = lib.mkIf (cfgl.userGroup != null) [ { 253 | long_name = "Matrix Users"; 254 | name = cfgl.userGroup; 255 | permissions = { }; 256 | } ]; 257 | 258 | systemd.services = lib.mkIf cfg.configurePostgres { 259 | # https://element-hq.github.io/synapse/latest/postgres.html#set-up-database 260 | # https://github.com/NixOS/nixpkgs/blob/nixos-unstable/nixos/modules/services/databases/postgresql.nix#L655 261 | postgresql.postStart = '' 262 | $PSQL -tAc "SELECT 1 FROM pg_database WHERE datname = 'matrix-synapse'" | grep -q 1 || $PSQL -tAc 'CREATE DATABASE "matrix-synapse" ENCODING="UTF8" LOCALE="C" TEMPLATE="template0" OWNER="matrix-synapse"' 263 | ''; 264 | }; 265 | 266 | users.users = lib.mkIf cfg.listenOnSocket { 267 | nginx.extraGroups = [ "matrix-synapse" ]; 268 | }; 269 | }; 270 | } 271 | -------------------------------------------------------------------------------- /modules/nextcloud.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.nextcloud; 5 | inherit (pkgs."nextcloud${lib.versions.major cfg.package.version}Packages") apps; 6 | in 7 | { 8 | options = { 9 | services.nextcloud = { 10 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 11 | 12 | configureImaginary = libS.mkOpinionatedOption "configure and use Imaginary for preview generation"; 13 | 14 | configureMemories = lib.mkEnableOption "" // { description = "Whether to configure dependencies for Memories App."; }; 15 | 16 | configureMemoriesVaapi = lib.mkOption { 17 | type = lib.types.bool; 18 | default = lib.hasAttr "driver" (config.hardware.intelgpu or { }); 19 | defaultText = lib.literalExpression ''lib.hasAttr "driver" config.hardware.intelgpu''; 20 | description = "Whether to configure Memories App to use an Intel iGPU for hardware acceleration."; 21 | }; 22 | 23 | configurePreviewSettings = lib.mkOption { 24 | type = lib.types.bool; 25 | default = cfg.configureImaginary; 26 | defaultText = lib.literalExpression "config.services.nextcloud.configureImaginary"; 27 | description = '' 28 | Whether to configure the preview settings to be more optimised for real world usage. 29 | By default this is enabled, when Imaginary is configured. 30 | ''; 31 | }; 32 | }; 33 | }; 34 | 35 | imports = [ 36 | (lib.mkRemovedOptionModule ["services" "nextcloud" "configureRecognize"] '' 37 | configureRecognize has been removed in favor of using the recognize packages from NixOS like: 38 | 39 | services.nextcloud.extraApps = { 40 | inherit (pkgs."nextcloud${lib.versions.major config.services.nextcloud.package.version}Packages".apps) recognize; 41 | }; 42 | '') 43 | ]; 44 | 45 | config = lib.mkIf cfg.enable { 46 | services = { 47 | imaginary = lib.mkIf cfg.configureImaginary { 48 | enable = true; 49 | address = "127.0.0.1"; 50 | settings.return-size = true; 51 | }; 52 | 53 | nextcloud = { 54 | extraApps = lib.mkMerge [ 55 | (lib.mkIf cfg.configureMemories { 56 | inherit (apps) memories; 57 | }) 58 | (lib.mkIf cfg.configurePreviewSettings { 59 | inherit (apps) previewgenerator; 60 | }) 61 | ]; 62 | 63 | phpOptions = lib.mkIf cfg.recommendedDefaults { 64 | # https://docs.nextcloud.com/server/latest/admin_manual/installation/server_tuning.html#:~:text=opcache.jit%20%3D%201255%20opcache.jit_buffer_size%20%3D%20128m 65 | "opcache.jit" = 1255; 66 | "opcache.jit_buffer_size" = "128M"; 67 | }; 68 | 69 | settings = lib.mkMerge [ 70 | (lib.mkIf cfg.recommendedDefaults { 71 | # otherwise the Logging App does not function 72 | log_type = "file"; 73 | }) 74 | 75 | (lib.mkIf cfg.configureImaginary { 76 | enabledPreviewProviders = [ 77 | # default from https://github.com/nextcloud/server/blob/master/config/config.sample.php#L1295-L1304 78 | ''OC\Preview\BMP'' 79 | ''OC\Preview\GIF'' 80 | ''OC\Preview\JPEG'' 81 | ''OC\Preview\Krita'' 82 | ''OC\Preview\MarkDown'' 83 | ''OC\Preview\MP3'' 84 | ''OC\Preview\OpenDocument'' 85 | ''OC\Preview\PNG'' 86 | ''OC\Preview\TXT'' 87 | ''OC\Preview\XBitmap'' 88 | # https://docs.nextcloud.com/server/24/admin_manual/installation/server_tuning.html#previews 89 | ''OC\Preview\Imaginary'' 90 | ]; 91 | 92 | preview_imaginary_url = "http://127.0.0.1:${toString config.services.imaginary.port}/"; 93 | }) 94 | 95 | (lib.mkIf cfg.configureMemories { 96 | enabledPreviewProviders = [ 97 | # https://memories.gallery/file-types/ 98 | ''OC\Preview\Image'' # alias for png,jpeg,gif,bmp 99 | ''OC\Preview\HEIC'' 100 | ''OC\Preview\TIFF'' 101 | ''OC\Preview\Movie'' 102 | ]; 103 | 104 | "memories.exiftool_no_local" = false; 105 | "memories.exiftool" = "${apps.memories}/bin-ext/exiftool/exiftool"; 106 | "memories.vod.ffmpeg" = "${apps.memories}/bin-ext/ffmpeg"; 107 | "memories.vod.ffprobe" = "${apps.memories}/bin-ext/ffprobe"; 108 | "memories.vod.path" = "${apps.memories}/bin-ext/go-vod"; 109 | "memories.vod.vaapi" = lib.mkIf cfg.configureMemoriesVaapi true; 110 | }) 111 | 112 | (lib.mkIf cfg.configurePreviewSettings { 113 | enabledPreviewProviders = [ 114 | # https://github.com/nextcloud/server/tree/master/lib/private/Preview 115 | ''OC\Preview\Font'' 116 | ''OC\Preview\PDF'' 117 | ''OC\Preview\SVG'' 118 | ''OC\Preview\WebP'' 119 | ]; 120 | 121 | enable_previews = true; 122 | jpeg_quality = 60; 123 | preview_max_filesize_image = 128; # MB 124 | preview_max_memory = 512; # MB 125 | preview_max_x = 2048; # px 126 | preview_max_y = 2048; # px 127 | }) 128 | ]; 129 | }; 130 | 131 | phpfpm.pools = lib.mkIf cfg.configurePreviewSettings { 132 | # add user packages to phpfpm process PATHs, required to find ffmpeg for preview generator 133 | # beginning taken from https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/services/web-apps/nextcloud.nix#L985 134 | nextcloud.phpEnv.PATH = lib.mkForce "/run/wrappers/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/usr/bin:/bin:/etc/profiles/per-user/nextcloud/bin"; 135 | }; 136 | }; 137 | 138 | systemd = { 139 | services = let 140 | occ = "/run/current-system/sw/bin/nextcloud-occ"; 141 | in { 142 | nextcloud-cron-preview-generator = lib.mkIf cfg.configurePreviewSettings { 143 | environment.NEXTCLOUD_CONFIG_DIR = "${cfg.datadir}/config"; 144 | serviceConfig = { 145 | ExecStart = "${occ} preview:pre-generate"; 146 | Type = "oneshot"; 147 | User = "nextcloud"; 148 | }; 149 | }; 150 | 151 | nextcloud-preview-generator-setup = lib.mkIf cfg.configurePreviewSettings { 152 | wantedBy = [ "multi-user.target" ]; 153 | requires = [ "phpfpm-nextcloud.service" ]; 154 | after = [ "phpfpm-nextcloud.service" ]; 155 | environment.NEXTCLOUD_CONFIG_DIR = "${cfg.datadir}/config"; 156 | script = /* bash */ '' 157 | # check with: 158 | # for size in squareSizes widthSizes heightSizes; do echo -n "$size: "; nextcloud-occ config:app:get previewgenerator $size; done 159 | 160 | # extra commands run for preview generator: 161 | # 32 icon file list 162 | # 64 icon file list android app, photos app 163 | # 96 nextcloud client VFS windows file preview 164 | # 256 file app grid view, many requests 165 | # 512 photos app tags 166 | ${occ} config:app:set --value="32 64 96 256 512" previewgenerator squareSizes 167 | 168 | # 341 hover in maps app 169 | # 1920 files/photos app when viewing picture 170 | ${occ} config:app:set --value="341 1920" previewgenerator widthSizes 171 | 172 | # 256 hover in maps app 173 | # 1080 files/photos app when viewing picture 174 | ${occ} config:app:set --value="256 1080" previewgenerator heightSizes 175 | ''; 176 | serviceConfig = { 177 | Type = "oneshot"; 178 | User = "nextcloud"; 179 | }; 180 | }; 181 | 182 | phpfpm-nextcloud.serviceConfig = lib.mkIf (cfg.configureMemories && cfg.configureMemoriesVaapi) { 183 | DeviceAllow = [ "/dev/dri/renderD128 rwm" ]; 184 | PrivateDevices = lib.mkForce false; 185 | }; 186 | }; 187 | 188 | timers.nextcloud-cron-preview-generator = lib.mkIf cfg.configurePreviewSettings { 189 | after = [ "nextcloud-setup.service" ]; 190 | timerConfig = { 191 | OnCalendar = "*:0/10"; 192 | OnUnitActiveSec = "9m"; 193 | Persistent = true; 194 | RandomizedDelaySec = 60; 195 | Unit = "nextcloud-cron-preview-generator.service"; 196 | }; 197 | wantedBy = [ "timers.target" ]; 198 | }; 199 | }; 200 | 201 | users.users.nextcloud = { 202 | extraGroups = lib.mkIf (cfg.configureMemories && cfg.configureMemoriesVaapi) [ 203 | "render" # access /dev/dri/renderD128 204 | ]; 205 | # generate video thumbnails with preview generator 206 | packages = lib.optional cfg.configurePreviewSettings pkgs.ffmpeg-headless; 207 | }; 208 | }; 209 | } 210 | -------------------------------------------------------------------------------- /modules/nginx.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.nginx; 5 | in 6 | { 7 | options.services.nginx = { 8 | allRecommendOptions = libS.mkOpinionatedOption "set all upstream options starting with `recommended`"; 9 | 10 | commonServerConfig = lib.mkOption { 11 | type = lib.types.lines; 12 | default = ""; 13 | description = "Shared configuration snipped added to every virtualHosts' extraConfig."; 14 | }; 15 | 16 | compileWithAWSlc = libS.mkOpinionatedOption "compile nginx with aws-lc as crypto library"; 17 | 18 | configureQuic = lib.mkEnableOption "quic support in nginx"; 19 | 20 | default404Server = { 21 | enable = lib.mkOption { 22 | type = lib.types.bool; 23 | default = false; 24 | description = '' 25 | Whether to add a default server which always responds with 404. 26 | This is useful when using a wildcard cname with a wildcard certitificate to not return the first server entry in the config on unknown subdomains 27 | or to do the same for an old and not fully removed domain. 28 | The addresses to listen on are derived from services.nginx.defaultListenAddresses. 29 | ''; 30 | }; 31 | 32 | acmeHost = lib.mkOption { 33 | type = lib.types.str; 34 | description = "The acme host to use for the default 404 server."; 35 | }; 36 | }; 37 | 38 | generateDhparams = libS.mkOpinionatedOption "generate more secure, 2048 bits dhparams replacing the default 1024 bits"; 39 | 40 | openFirewall = libS.mkOpinionatedOption "open the firewall port for the http (80/tcp), https (443/tcp) and if enabled quic (443/udp) ports"; 41 | 42 | recommendedDefaults = libS.mkOpinionatedOption "set recommended performance options not grouped into other settings"; 43 | 44 | resolverAddrFromNameserver = libS.mkOpinionatedOption "set resolver address to environment.nameservers"; 45 | 46 | rotateLogsFaster = libS.mkOpinionatedOption "keep logs only for 7 days and rotate them daily"; 47 | 48 | hstsHeader = { 49 | enable = libS.mkOpinionatedOption "add the `Strict-Transport-Security` (HSTS) header to all virtual hosts"; 50 | 51 | includeSubDomains = lib.mkEnableOption "" // { description = "Whether to add `includeSubDomains` to the `Strict-Transport-Security` header"; }; 52 | }; 53 | 54 | tcpFastOpen = lib.mkEnableOption "" // { description = "Whether to configure tcp fast open. This requires configuring useACMEHost for `_` due to limitatons in the nginx config parser"; }; 55 | 56 | # source https://gist.github.com/danbst/f1e81358d5dd0ba9c763a950e91a25d0 57 | virtualHosts = lib.mkOption { 58 | type = with lib.types; attrsOf (submodule ({ config, ... }: let 59 | cfgv = config; 60 | in { 61 | options = { 62 | commonLocationsConfig = lib.mkOption { 63 | type = lib.types.lines; 64 | default = ""; 65 | description = '' 66 | Shared configuration snipped added to every locations' extraConfig. 67 | 68 | ::: {.note} 69 | This option mainly exists because nginx' add_header and headers_more's more_set_headers function do not support inheritance to lower levels. 70 | ::: 71 | ''; 72 | }; 73 | 74 | locations = lib.mkOption { 75 | type = with lib.types; attrsOf (submodule { 76 | options.extraConfig = lib.mkOption { }; 77 | config.extraConfig = lib.optionalString cfg.hstsHeader.enable /* nginx */ '' 78 | more_set_headers "Strict-Transport-Security: max-age=63072000; ${lib.optionalString cfg.hstsHeader.includeSubDomains "includeSubDomains; "}preload"; 79 | '' + cfg.commonServerConfig + cfgv.commonLocationsConfig; 80 | }); 81 | }; 82 | }; 83 | 84 | config.kTLS = lib.mkIf cfg.compileWithAWSlc false; 85 | })); 86 | }; 87 | }; 88 | 89 | imports = [ 90 | (lib.mkRenamedOptionModule [ "services" "nginx" "allCompression" ] [ "services" "nginx" "allRecommendOptions" ]) 91 | (lib.mkRenamedOptionModule [ "services" "nginx" "quic" "bpf" ] [ "services" "nginx" "enableQuicBPF" ]) 92 | (lib.mkRenamedOptionModule [ "services" "nginx" "quic" "enable" ] [ "services" "nginx" "configureQuic" ]) 93 | (lib.mkRenamedOptionModule [ "services" "nginx" "setHSTSHeader" ] [ "services" "nginx" "hstsHeader" "enable" ]) 94 | ]; 95 | 96 | config = lib.mkIf cfg.enable { 97 | assertions = lib.mkIf cfg.hstsHeader.enable (lib.flatten (lib.attrValues (lib.mapAttrs (host: hostConfig: let 98 | name = ''services.nginx.virtualHosts."${host}"''; 99 | in [ 100 | { 101 | assertion = (lib.length (lib.attrNames hostConfig.locations)) == 0 -> hostConfig.root == null; 102 | message = "Use ${name}.locations./.root instead of ${name}.root to properly apply .locations.*.extraConfig set by `services.nginx.hstsHeader.enable`."; 103 | } 104 | { 105 | assertion = cfg.compileWithAWSlc -> !hostConfig.kTLS; 106 | message = "${name} uses kTLS which is incompatible with aws-lc."; 107 | } 108 | ]) cfg.virtualHosts))); 109 | 110 | boot.kernel.sysctl = lib.mkIf cfg.tcpFastOpen { 111 | # enable tcp fastopen for outgoing and incoming connections 112 | "net.ipv4.tcp_fastopen" = 3; 113 | }; 114 | 115 | networking.firewall = lib.mkIf cfg.openFirewall { 116 | allowedTCPPorts = [ 80 443 ]; 117 | allowedUDPPorts = lib.mkIf cfg.configureQuic [ 443 ]; 118 | }; 119 | 120 | nixpkgs.overlays = lib.mkIf cfg.tcpFastOpen [ 121 | (final: prev: 122 | let 123 | configureFlags = [ "-DTCP_FASTOPEN=23" ]; 124 | in 125 | { 126 | nginx = prev.nginx.override { inherit configureFlags; }; 127 | nginxQuic = prev.nginxQuic.override { inherit configureFlags; }; 128 | nginxStable = prev.nginxStable.override { inherit configureFlags; }; 129 | nginxMainline = prev.nginxMainline.override { inherit configureFlags; }; 130 | }) 131 | ]; 132 | 133 | services = { 134 | logrotate.settings.nginx = lib.mkIf cfg.rotateLogsFaster { 135 | frequency = "daily"; 136 | rotate = 7; 137 | }; 138 | 139 | # NOTE: do not use mkMerge here to prevent infinite recursions 140 | nginx = { 141 | appendConfig = lib.optionalString cfg.recommendedDefaults /* nginx */ '' 142 | worker_processes auto; 143 | worker_cpu_affinity auto; 144 | ''; 145 | 146 | commonHttpConfig = lib.optionalString cfg.recommendedDefaults /* nginx */ '' 147 | error_log syslog:server=unix:/dev/log; 148 | '' + lib.optionalString cfg.configureQuic /* nginx */'' 149 | quic_retry on; 150 | ''; 151 | 152 | enableQuicBPF = lib.mkIf cfg.configureQuic true; 153 | 154 | package = let 155 | overrideNginx = pkg: 156 | if cfg.compileWithAWSlc then 157 | (pkg.override { 158 | openssl = pkgs.aws-lc; 159 | }).overrideAttrs ({ patches ? [ ], ... }: { 160 | patches = patches ++ [ 161 | (pkgs.fetchpatch { 162 | url = "https://github.com/aws/aws-lc/raw/refs/tags/v${pkgs.aws-lc.version}/tests/ci/integration/nginx_patch/aws-lc-nginx.patch"; 163 | hash = "sha256-6OPLpt0hVDPdG70eJrwehwcX3i9N5lkvaeVaAjFSByM="; 164 | }) 165 | ]; 166 | }) 167 | else 168 | pkg; 169 | in lib.mkIf (cfg.configureQuic || cfg.compileWithAWSlc || cfg.recommendedDefaults) (overrideNginx ( 170 | if cfg.configureQuic then 171 | pkgs.nginxQuic 172 | else 173 | # use the newer version 174 | if lib.versionOlder pkgs.nginx.version pkgs.nginxMainline.version then 175 | pkgs.nginxMainline 176 | else 177 | pkgs.nginx 178 | )); 179 | 180 | recommendedBrotliSettings = lib.mkIf cfg.allRecommendOptions (lib.mkDefault true); 181 | recommendedGzipSettings = lib.mkIf cfg.allRecommendOptions (lib.mkDefault true); 182 | recommendedOptimisation = lib.mkIf cfg.allRecommendOptions (lib.mkDefault true); 183 | recommendedProxySettings = lib.mkIf cfg.allRecommendOptions (lib.mkDefault true); 184 | recommendedTlsSettings = lib.mkIf cfg.allRecommendOptions (lib.mkDefault true); 185 | recommendedZstdSettings = lib.mkIf cfg.allRecommendOptions (lib.mkDefault true); 186 | 187 | resolver.addresses = 188 | let 189 | isIPv6 = addr: builtins.match ".*:.*:.*" addr != null; 190 | escapeIPv6 = entry: 191 | let 192 | # cut off potential domain name from DoT 193 | addr = toString (lib.take 1 (builtins.split "#" entry)); 194 | in 195 | if isIPv6 addr then 196 | "[${addr}]" 197 | else 198 | addr; 199 | in 200 | lib.optionals (cfg.resolverAddrFromNameserver && config.networking.nameservers != [ ]) (map escapeIPv6 config.networking.nameservers); 201 | sslDhparam = lib.mkIf cfg.generateDhparams config.security.dhparams.params.nginx.path; 202 | 203 | # NOTE: do not use mkMerge here to prevent infinite recursions 204 | virtualHosts = 205 | let 206 | extraParameters = [ 207 | # net.core.somaxconn is set to 4096 208 | # see https://www.nginx.com/blog/tuning-nginx/#:~:text=to%20a%20value-,greater%20than%20512,-%2C%20change%20the%20backlog 209 | "backlog=1024" 210 | 211 | "deferred" 212 | "fastopen=256" # requires nginx to be compiled with -DTCP_FASTOPEN=23 213 | ]; 214 | in 215 | lib.mkIf (cfg.recommendedDefaults || cfg.default404Server.enable || cfg.configureQuic) { 216 | "_" = { 217 | kTLS = lib.mkIf (cfg.recommendedDefaults && !cfg.compileWithAWSlc) true; 218 | reuseport = lib.mkIf (cfg.recommendedDefaults || cfg.configureQuic) true; 219 | 220 | default = lib.mkIf cfg.default404Server.enable true; 221 | addSSL = lib.mkIf cfg.default404Server.enable true; 222 | useACMEHost = lib.mkIf cfg.default404Server.enable cfg.default404Server.acmeHost; 223 | locations = lib.mkIf cfg.default404Server.enable { 224 | "/".return = 404; 225 | }; 226 | 227 | listen = lib.mkIf cfg.tcpFastOpen (lib.mkDefault (lib.flatten (map (addr: [ 228 | { inherit addr; port = 80; inherit extraParameters; } 229 | { inherit addr; port = 443; ssl = true; inherit extraParameters; } 230 | ]) config.services.nginx.defaultListenAddresses))); 231 | 232 | quic = lib.mkIf cfg.configureQuic true; 233 | }; 234 | }; 235 | }; 236 | }; 237 | 238 | security.dhparams = lib.mkIf cfg.generateDhparams { 239 | enable = cfg.generateDhparams; 240 | params.nginx = { }; 241 | }; 242 | 243 | systemd.services.nginx.restartTriggers = lib.mkIf cfg.recommendedDefaults [ config.users.users.${cfg.user}.extraGroups ]; 244 | }; 245 | } 246 | -------------------------------------------------------------------------------- /modules/nix.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.nix; 5 | 6 | # based on https://gist.github.com/Ma27/6650d10f772511931647d3189b3eb1d7 7 | diffBoot = /* bash */ '' 8 | if [[ "''${NIXOS_ACTION-}" == boot && -e /run/current-system && -e "''${1-}" ]]; then 9 | ( 10 | unset PS4 11 | set -x 12 | ${lib.getExe cfg.package} --extra-experimental-features nix-command store diff-closures /run/current-system "''${1-}" 13 | ) 14 | fi 15 | ''; 16 | in 17 | { 18 | options.nix = { 19 | deleteChannels = lib.mkEnableOption "" // { description = "Whether to delete all channels on a system activation and switch."; }; 20 | 21 | deleteUserProfiles = lib.mkEnableOption "" // { description = "Whether to delete all user profiles on a system activation and switch."; }; 22 | 23 | diffSystem = libS.mkOpinionatedOption "diff system closure on activation and switch"; 24 | 25 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 26 | 27 | remoteBuilder = { 28 | enable = lib.mkEnableOption "" // { 29 | description = '' 30 | Whether to configure a restricted user for nix remote building on this host. 31 | 32 | To use the remote builder on another NixOS machine, you need to configure the following there: 33 | 34 | ```nix 35 | nix.buildMachines = { 36 | hostName = "hostname.example.com"; 37 | maxJobs = 4; 38 | protocol = "ssh-ng"; 39 | speedFactor = 2; 40 | sshUser = "nix-remote-builder"; 41 | supportedFeatures = [ "big-parallel" ]; 42 | systems = [ "x86_64-linux" "i686-linux" ]; 43 | }; 44 | ``` 45 | ''; 46 | }; 47 | 48 | sshPublicKeys = lib.mkOption { 49 | type = lib.types.listOf lib.types.str; 50 | example = [ "ssh-ed25519 AAA....tGz user" ]; 51 | description = "SSH public keys accepted by the remote build user."; 52 | }; 53 | 54 | name = lib.mkOption { 55 | type = lib.types.str; 56 | default = "nix-remote-builder"; 57 | description = "Name of the user used for remote building."; 58 | }; 59 | }; 60 | }; 61 | 62 | config = { 63 | boot.loader = { 64 | grub.extraInstallCommands = lib.mkIf cfg.diffSystem diffBoot; 65 | systemd-boot.extraInstallCommands = lib.mkIf cfg.diffSystem diffBoot; 66 | }; 67 | 68 | nix.settings = { 69 | builders-use-substitutes = lib.mkIf cfg.recommendedDefaults true; 70 | connect-timeout = lib.mkIf cfg.recommendedDefaults (lib.mkDefault 5); # Nix 2.29 default 71 | experimental-features = lib.mkIf cfg.recommendedDefaults [ "nix-command" "flakes" ]; 72 | trusted-users = lib.mkIf cfg.remoteBuilder.enable [ cfg.remoteBuilder.name ]; 73 | }; 74 | 75 | # flakes require a git in PATH 76 | programs.git.enable = lib.mkIf cfg.recommendedDefaults true; 77 | 78 | # based on https://github.com/numtide/srvos/blob/main/nixos/roles/nix-remote-builder.nix 79 | # and https://discourse.nixos.org/t/wrapper-to-restrict-builder-access-through-ssh-worth-upstreaming/25834 80 | users.users.${cfg.remoteBuilder.name} = lib.mkIf cfg.remoteBuilder.enable { 81 | group = "nogroup"; 82 | isNormalUser = true; 83 | openssh.authorizedKeys.keys = map 84 | (key: 85 | let 86 | wrapper-dispatch-ssh-nix = pkgs.writeShellScriptBin "wrapper-dispatch-ssh-nix" /* bash */ '' 87 | case $SSH_ORIGINAL_COMMAND in 88 | "nix-daemon --stdio") 89 | exec ${lib.getExe' cfg.package "nix-daemon"} --stdio 90 | ;; 91 | "nix-store --serve --write") 92 | exec ${lib.getExe' cfg.package "nix-store"} --serve --write 93 | ;; 94 | # used by nixos-rebuild --target-host ... --build-host ... 95 | "nix-store -r") 96 | exec ${lib.getExe' cfg.package "nix-store"} -r 97 | ;; 98 | *) 99 | echo "Access is only allowed for nix remote building, not running command \"$SSH_ORIGINAL_COMMAND\"" 1>&2 100 | exit 1 101 | esac 102 | ''; 103 | 104 | in 105 | "restrict,pty,command=\"${lib.getExe wrapper-dispatch-ssh-nix}\" ${key}" 106 | ) 107 | cfg.remoteBuilder.sshPublicKeys; 108 | }; 109 | 110 | system = { 111 | activationScripts = { 112 | deleteChannels = lib.mkIf cfg.deleteChannels /* bash */ '' 113 | echo "Deleting all channels..." 114 | rm -rfv /root/{.local/state/nix/defexpr,.nix-channels,.nix-defexpr} /home/*/{.local/state/nix/defexpr,.nix-channels,.nix-defexpr} /nix/var/nix/profiles/per-user/*/channels* || true 115 | ''; 116 | 117 | deleteUserProfiles = lib.mkIf cfg.deleteUserProfiles /* bash */ '' 118 | echo "Deleting all user profiles..." 119 | rm -rfv /root/{.local/state/nix/profile,.nix-profile} /home/*/{.local/state/nix/profile,.nix-profile} /nix/var/nix/profiles/per-user/*/profile* || true 120 | ''; 121 | 122 | diff-system = lib.mkIf cfg.diffSystem { 123 | supportsDryActivation = true; 124 | text = /* bash */ '' 125 | if [[ -e /run/current-system && -e $systemConfig ]]; then 126 | echo 127 | echo nix diff new system against /run/current-system 128 | ( 129 | unset PS4 130 | set -x 131 | ${lib.getExe cfg.package} --extra-experimental-features nix-command store diff-closures /run/current-system $systemConfig || true 132 | ) 133 | echo 134 | fi 135 | ''; 136 | }; 137 | }; 138 | 139 | build.installBootLoader = lib.mkIf cfg.diffSystem (lib.mkMerge [ 140 | (lib.mkIf config.boot.isContainer (pkgs.writeShellScript "diff-closures-on-nspawn" diffBoot)) 141 | (lib.mkIf (config.boot.loader.external.enable && !config.boot.isContainer) (lib.mkForce (pkgs.writeShellScript "install-bootloader-external" '' 142 | ${diffBoot} 143 | exec ${config.boot.loader.external.installHook} "$@" 144 | ''))) 145 | ]); 146 | }; 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /modules/no-graphics-packages.nix: -------------------------------------------------------------------------------- 1 | # This module continues the upstream removed option environment.noXlibs 2 | 3 | { config, lib, options, pkgs, ... }: 4 | 5 | let 6 | cfg = config.environment.noGraphicsPackages; 7 | in 8 | { 9 | meta.maintainers = [ lib.maintainers.SuperSandro2000 ]; 10 | 11 | options = { 12 | environment.noGraphicsPackages = lib.mkOption { 13 | type = lib.types.bool; 14 | default = false; 15 | description = '' 16 | This is an advanced option that switches off options in the default configuration that require GUI libraries 17 | and adds overlays to remove such dependencies in some packages. 18 | This includes client-side font configuration and SSH forwarding of X11 authentication. 19 | Thus, you do *not* want to enable this option on a graphical system or if you want to run X11 programs via SSH. 20 | 21 | ::: {.warning} 22 | The added overlays cause package rebuilds due to cache misses. 23 | Also some packages might fail to build due to the added overlays. 24 | When enabling this option you should be able to recognize such build failures and act on them accordingly. 25 | ::: 26 | ''; 27 | }; 28 | }; 29 | 30 | config = { 31 | assertions = lib.mkIf cfg [ 32 | # copied from lib.mkRemovedOptionModule 33 | (let 34 | optionName = [ "environment" "noXlibs" ]; 35 | replacementInstructions = "This option got renamed to environment.noGraphicsPackages. Please make sure to properly read the description of the option if you want to continue to use it."; 36 | opt = lib.getAttrFromPath optionName options; 37 | in { 38 | assertion = !opt.isDefined; 39 | message = '' 40 | The option definition `${lib.showOption optionName}' in ${lib.showFiles opt.files} no longer has any effect; please remove it. 41 | ${replacementInstructions} 42 | ''; 43 | }) 44 | 45 | { 46 | assertion = !config.services.graphical-desktop.enable && !config.services.xserver.enable; 47 | message = "environment.noGraphicsPackages requires that no graphical desktop is being used! Please unset this option."; 48 | } 49 | ]; 50 | 51 | fonts.fontconfig.enable = lib.mkIf cfg false; 52 | 53 | nixpkgs.overlays = lib.singleton (lib.const (prev: (lib.mapAttrs (name: value: if cfg then value else prev.${name}) { 54 | beam = prev.beam_nox; 55 | cairo = prev.cairo.override { x11Support = false; }; 56 | dbus = prev.dbus.override { x11Support = false; }; 57 | fastfetch = prev.fastfetch.override { vulkanSupport = false; waylandSupport = false; x11Support = false; }; 58 | ffmpeg = prev.ffmpeg.override { ffmpegVariant = "headless"; }; 59 | ffmpeg_4 = prev.ffmpeg_4.override { ffmpegVariant = "headless"; }; 60 | ffmpeg_6 = prev.ffmpeg_6.override { ffmpegVariant = "headless"; }; 61 | ffmpeg_7 = prev.ffmpeg_7.override { ffmpegVariant = "headless"; }; 62 | # dep of graphviz, libXpm is optional for Xpm support 63 | gd = prev.gd.override { withXorg = false; }; 64 | ghostscript = prev.ghostscript.override { cupsSupport = false; x11Support = false; }; 65 | gjs = (prev.gjs.override { installTests = false; }).overrideAttrs { doCheck = false; }; # avoid test dependency on gtk3 66 | gobject-introspection = prev.gobject-introspection.override { x11Support = false; }; 67 | gpg-tui = prev.gpg-tui.override { x11Support = false; }; 68 | gpsd = prev.gpsd.override { guiSupport = false; }; 69 | graphviz = prev.graphviz-nox; 70 | gst_all_1 = prev.gst_all_1 // { 71 | gst-plugins-bad = prev.gst_all_1.gst-plugins-bad.override { guiSupport = false; }; 72 | gst-plugins-base = prev.gst_all_1.gst-plugins-base.override { enableGl = false; enableWayland = false; enableX11 = false; }; 73 | gst-plugins-good = prev.gst_all_1.gst-plugins-good.override { enableWayland = false; enableX11 = false; gtkSupport = false; qt5Support = false; qt6Support = false; }; 74 | gst-plugins-rs = prev.gst_all_1.gst-plugins-rs.override { withGtkPlugins = false; }; 75 | }; 76 | imagemagick = prev.imagemagick.override { libX11Support = false; libXtSupport = false; }; 77 | imagemagickBig = prev.imagemagickBig.override { libX11Support = false; libXtSupport = false; }; 78 | intel-vaapi-driver = prev.intel-vaapi-driver.override { enableGui = false; }; 79 | libdevil = prev.libdevil-nox; 80 | libextractor = prev.libextractor.override { gtkSupport = false; }; 81 | libplacebo = prev.libplacebo.override { vulkanSupport = false; }; 82 | libva = prev.libva-minimal; 83 | limesuite = prev.limesuite.override { withGui = false; }; 84 | mc = prev.mc.override { x11Support = false; }; 85 | # TODO: remove when https://github.com/NixOS/nixpkgs/pull/344318 is merged 86 | mesa = (prev.mesa.override { eglPlatforms = [ ]; }).overrideAttrs ({ mesonFlags, ... }:{ 87 | mesonFlags = mesonFlags ++ [ 88 | (lib.mesonEnable "gallium-vdpau" false) 89 | (lib.mesonEnable "glx" false) 90 | (lib.mesonEnable "xlib-lease" false) 91 | ]; 92 | }); 93 | mpv-unwrapped = prev.mpv-unwrapped.override ({ 94 | drmSupport = false; 95 | sdl2Support = false; 96 | vulkanSupport = false; 97 | waylandSupport = false; 98 | x11Support = false; 99 | } // lib.optionalAttrs (prev.mpv-unwrapped.override.__functionArgs?screenSaverSupport) { 100 | screenSaverSupport = false; 101 | }); 102 | msmtp = prev.msmtp.override { withKeyring = false; }; 103 | mupdf = prev.mupdf.override { enableGL = false; enableX11 = false; }; 104 | neofetch = prev.neofetch.override { x11Support = false; }; 105 | networkmanager-fortisslvpn = prev.networkmanager-fortisslvpn.override { withGnome = false; }; 106 | networkmanager-iodine = prev.networkmanager-iodine.override { withGnome = false; }; 107 | networkmanager-l2tp = prev.networkmanager-l2tp.override { withGnome = false; }; 108 | networkmanager-openconnect = prev.networkmanager-openconnect.override { withGnome = false; }; 109 | networkmanager-openvpn = prev.networkmanager-openvpn.override { withGnome = false; }; 110 | networkmanager-sstp = prev.networkmanager-vpnc.override { withGnome = false; }; 111 | networkmanager-vpnc = prev.networkmanager-vpnc.override { withGnome = false; }; 112 | pango = prev.pango.override { x11Support = false; }; 113 | pinentry-curses = prev.pinentry-curses.override { withLibsecret = false; }; 114 | pinentry-tty = prev.pinentry-tty.override { withLibsecret = false; }; 115 | pipewire = prev.pipewire.override { vulkanSupport = false; x11Support = false; }; 116 | pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [ 117 | (python-final: python-prev: { 118 | # tk feature requires wayland which fails to compile 119 | matplotlib = python-prev.matplotlib.override { enableTk = false; }; 120 | }) 121 | ]; 122 | qemu = prev.qemu.override { gtkSupport = false; spiceSupport = false; sdlSupport = false; }; 123 | qrencode = prev.qrencode.overrideAttrs (_: { doCheck = false; }); 124 | qt5 = prev.qt5.overrideScope (lib.const (prev': { 125 | qtbase = prev'.qtbase.override { withGtk3 = false; withQttranslation = false; }; 126 | })); 127 | stoken = prev.stoken.override { withGTK3 = false; }; 128 | # avoid kernel rebuild through swtpm -> tpm2-tss -> systemd -> util-linux -> hexdump 129 | swtpm = let 130 | gobject-introspection = prev.gobject-introspection.override { inherit (prev) cairo; }; 131 | glib = prev.glib.override { inherit gobject-introspection; }; 132 | in prev.swtpm.override { 133 | inherit glib; 134 | json-glib = prev.json-glib.override { 135 | inherit glib gobject-introspection; 136 | }; 137 | }; 138 | # translateManpages -> perlPackages.po4a -> texlive-combined-basic -> texlive-core-big -> libX11 139 | util-linux = prev.util-linux.override { translateManpages = false; }; 140 | vim-full = prev.vim-full.override { guiSupport = false; }; 141 | vte = prev.vte.override { gtkVersion = null; }; 142 | # TODO: upstream as toggle 143 | vulkan-loader = prev.vulkan-loader.override { wayland = null; }; 144 | wayland = prev.wayland.override { withDocumentation = false; }; 145 | zbar = prev.zbar.override { enableVideo = false; withXorg = false; }; 146 | }))); 147 | 148 | programs.ssh.setXAuthLocation = lib.mkIf cfg false; 149 | 150 | security.pam.services.su.forwardXAuth = lib.mkIf cfg (lib.mkForce false); 151 | }; 152 | } 153 | -------------------------------------------------------------------------------- /modules/nvidia.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | let 4 | cfg = config.hardware; 5 | in 6 | { 7 | options.hardware = { 8 | nvidiaGPU = lib.mkEnableOption "" // { description = "Whether to add and configure drivers for NVidia hardware acceleration."; }; 9 | }; 10 | 11 | config = lib.mkIf cfg.nvidiaGPU { 12 | environment.sessionVariables = { 13 | # source https://github.com/elFarto/nvidia-vaapi-driver#firefox 14 | LIBVA_DRIVER_NAME = "nvidia"; 15 | }; 16 | 17 | hardware = { 18 | graphics.enable = true; 19 | nvidia = { 20 | modesetting.enable = true; 21 | nvidiaSettings = true; 22 | }; 23 | }; 24 | 25 | programs.firefox.hardwareAcceleration = true; 26 | 27 | services.xserver.videoDrivers = [ "nvidia" ]; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /modules/portunus-remove-add-group.diff: -------------------------------------------------------------------------------- 1 | diff --git a/internal/frontend/core.go b/internal/frontend/core.go 2 | index 5976377..7c67991 100644 3 | --- a/internal/frontend/core.go 4 | +++ b/internal/frontend/core.go 5 | @@ -43,8 +43,6 @@ func HTTPHandler(nexus core.Nexus, isBehindTLSProxy bool) http.Handler { 6 | r.Methods("POST").Path(`/users/{uid}/delete`).Handler(postUserDeleteHandler(nexus)) 7 | 8 | r.Methods("GET").Path(`/groups`).Handler(getGroupsHandler(nexus)) 9 | - r.Methods("GET").Path(`/groups/new`).Handler(getGroupsNewHandler(nexus)) 10 | - r.Methods("POST").Path(`/groups/new`).Handler(postGroupsNewHandler(nexus)) 11 | r.Methods("GET").Path(`/groups/{name}/edit`).Handler(getGroupEditHandler(nexus)) 12 | r.Methods("POST").Path(`/groups/{name}/edit`).Handler(postGroupEditHandler(nexus)) 13 | r.Methods("GET").Path(`/groups/{name}/delete`).Handler(getGroupDeleteHandler(nexus)) 14 | diff --git a/internal/frontend/groups.go b/internal/frontend/groups.go 15 | index 5ac6a75..ac59f4f 100644 16 | --- a/internal/frontend/groups.go 17 | +++ b/internal/frontend/groups.go 18 | @@ -38,7 +38,6 @@ func getGroupsHandler(n core.Nexus) http.Handler { 19 | Members 20 | Permissions granted 21 | 22 | - New group 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /modules/portunus.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | let 4 | cfg = config.services.portunus; 5 | cfgd = config.services.dex; 6 | cfgo = config.services.oauth2-proxy; 7 | inherit (config.security) ldap; 8 | in 9 | { 10 | options.services = { 11 | dex = { 12 | discoveryEndpoint = lib.mkOption { 13 | type = lib.types.str; 14 | default = "${cfgd.settings.issuer}/.well-known/openid-configuration"; 15 | defaultText = "$''{config.services.dex.settings.issuer}/.well-known/openid-configuration"; 16 | description = "The discover endpoint of dex"; 17 | }; 18 | }; 19 | 20 | portunus = { 21 | # maybe based on $service.ldap.enable && services.portunus.enable? 22 | addToHosts = lib.mkOption { 23 | type = lib.types.bool; 24 | default = false; 25 | description = "Whether to add a hosts entry for the portunus domain pointing to externalIp"; 26 | }; 27 | 28 | # only here to fix manual creation 29 | domain = lib.mkOption { 30 | default = ""; 31 | }; 32 | 33 | internalIp4 = lib.mkOption { 34 | type = with lib.types; nullOr str; 35 | default = null; 36 | description = "Internal IPv4 of portunus instance. This is used in the addToHosts option."; 37 | }; 38 | 39 | internalIp6 = lib.mkOption { 40 | type = with lib.types; nullOr str; 41 | default = null; 42 | description = "Internal IPv6 of portunus instance. This is used in the addToHosts option."; 43 | }; 44 | 45 | ldapPreset = lib.mkOption { 46 | type = lib.types.bool; 47 | default = false; 48 | description = "Whether to set security.ldap to portunus specific settings."; 49 | }; 50 | 51 | oauth2-proxy = { 52 | configure = lib.mkOption { 53 | type = lib.types.bool; 54 | default = false; 55 | description = '' 56 | Whether to configure oauth2-proxy to work together with Dex and Portunus as a backend. 57 | 58 | If Portunus is enabled locally, the oidc client is configured in Dex, otherwise it must be done manually via `services.portunus.dex.oidcClients`. 59 | 60 | Use `services.oauth2-proxy.nginx.virtualHosts` to configure the nginx virtual hosts that should require authentication. 61 | ''; 62 | }; 63 | 64 | clientID = lib.mkOption { 65 | type = lib.types.str; 66 | default = "oauth2_proxy"; 67 | description = '' 68 | The client ID oauth2-proxy will be using. 69 | `-` is not allowed here, as it makes it impossible to configure the secret securely via an environment variable. 70 | ''; 71 | }; 72 | }; 73 | 74 | removeAddGroup = lib.mkOption { 75 | type = lib.types.bool; 76 | default = false; 77 | description = "When enabled, remove the function to add new Groups via the web ui, to enforce seeding usage."; 78 | }; 79 | 80 | seedGroups = lib.mkOption { 81 | type = lib.types.bool; 82 | default = false; 83 | description = "Whether to seed groups configured in services as not member managed groups."; 84 | }; 85 | 86 | webDomain = lib.mkOption { 87 | type = lib.types.str; 88 | default = ""; 89 | example = "auth.example.com"; 90 | description = "The domain name to connect to, to visit the ldap server web interface and to which to issue cookies to."; 91 | }; 92 | }; 93 | }; 94 | 95 | imports = [ 96 | (lib.mkRenamedOptionModule ["services" "portunus" "configureOAuth2Proxy"] ["services" "portunus" "oauth2-proxy" "configure"]) 97 | ]; 98 | 99 | config = { 100 | assertions = [ 101 | { 102 | assertion = cfg.oauth2-proxy.configure -> cfgo.keyFile != null; 103 | message = '' 104 | Setting services.portunus.configureOAuth2Proxy to true requires to set service.oauth2-proxy.keyFile 105 | to a file that contains `OAUTH2_PROXY_CLIENT_SECRET` and `OAUTH2_PROXY_COOKIE_SECRET`. 106 | ''; 107 | } 108 | { 109 | assertion = cfg.enable -> lib.versionAtLeast cfg.package.version "2.0.0"; 110 | message = "Portunus 2.0.0 is required for this module!"; 111 | } 112 | { 113 | assertion = cfg.enable -> cfg.domain != ""; 114 | message = "services.portunus.domain must be set to the domain name under which you can reach the *internal* Portunus ldaps port."; 115 | } 116 | { 117 | assertion = cfg.enable -> cfg.webDomain != ""; 118 | message = "services.portunus.webDomain must be set to the domain name under which you can reach the Portunus Web UI."; 119 | } 120 | ]; 121 | 122 | warnings = lib.optional cfg.addToHosts "services.portunus.addToHosts is deprecated! Please use security.ldap.domain instead." 123 | ++ lib.optional (cfg.internalIp4 != null) "services.portunus.internalIp4 is deprecated! Please use security.ldap.domain instead." 124 | ++ lib.optional (cfg.internalIp4 != null) "services.portunus.internalIp6 is deprecated! Please use security.ldap.domain instead."; 125 | 126 | networking.hosts = lib.mkIf cfg.addToHosts { 127 | ${cfg.internalIp4} = [ cfg.domain ]; 128 | ${cfg.internalIp6} = [ cfg.domain ]; 129 | }; 130 | 131 | nixpkgs.overlays = lib.mkIf cfg.enable [ 132 | (final: prev: with final; { 133 | dex-oidc = let 134 | functionArgs = prev.dex-oidc.override.__functionArgs; 135 | buildGoModule = if functionArgs?buildGoModule then 136 | "buildGoModule" 137 | else if functionArgs?buildGo124Module then 138 | "buildGo124Module" 139 | else throw "nixos-modules/portunus/dex: yet another buildGo*Module version..."; 140 | in prev.dex-oidc.override { 141 | "${buildGoModule}" = args: final."${buildGoModule}" (args // { 142 | patches = args.patches or [ ] ++ [ 143 | # remember session 144 | (if (lib.versionAtLeast prev.dex-oidc.version "2.42") then 145 | ./dex-session-cookie-password-connector-2.42.patch 146 | else if (lib.versionAtLeast prev.dex-oidc.version "2.41") then 147 | ./dex-session-cookie-password-connector-2.41.patch 148 | else if (lib.versionAtLeast prev.dex-oidc.version "2.40") then 149 | ./dex-session-cookie-password-connector-2.40.patch 150 | else 151 | throw "Dex version ${dex-oidc.version} is not supported." 152 | ) 153 | 154 | # Complain if the env set in SecretEnv cannot be found 155 | (fetchpatch { 156 | url = "https://github.com/dexidp/dex/commit/f25f72053c9282cfe22521cd508698a07dc5190f.patch"; 157 | hash = "sha256-dyo+UPpceHxL3gcBQaGaDAHJqmysDJw051gMG1aeh5o="; 158 | }) 159 | ]; 160 | 161 | vendorHash = if lib.versionAtLeast prev.dex-oidc.version "2.42" then 162 | "sha256-yBAr1pDhaJChtz8km9eDISc9aU+2JtKhetlS8CbClaE=" 163 | else if lib.versionAtLeast prev.dex-oidc.version "2.41" then 164 | "sha256-a0F4itrposTBeI1XB0Ru3wBkw2zMBlsMhZUW8PuM1NA=" 165 | else if lib.versionAtLeast prev.dex-oidc.version "2.40" then 166 | "sha256-oxu3eNsjUGo6Mh6QybeGggsCZsZOGYo7nBD5ZU8MSy8=" 167 | else 168 | throw "Dex version ${dex-oidc.version} is not supported."; 169 | }); 170 | }; 171 | 172 | portunus = prev.portunus.overrideAttrs ({ patches ? [ ], ... }: { 173 | patches = patches 174 | ++ lib.optional cfg.removeAddGroup ./portunus-remove-add-group.diff; 175 | }); 176 | }) 177 | ]; 178 | 179 | services = { 180 | dex.settings = { 181 | issuer = lib.mkForce "https://${cfg.webDomain}/dex"; 182 | # the user has no other option to accept this and all clients are internal anyway 183 | oauth2.skipApprovalScreen = true; 184 | }; 185 | 186 | oauth2-proxy = lib.mkIf cfg.oauth2-proxy.configure { 187 | enable = true; 188 | inherit (cfg.oauth2-proxy) clientID; 189 | # if Portunus is not enabled locally, its domain is most likely wrong 190 | nginx.domain = lib.mkIf cfg.enable cfg.webDomain; 191 | provider = "oidc"; 192 | redirectURL = "https://${cfgo.nginx.domain}/oauth2/callback"; 193 | reverseProxy = true; 194 | upstream = "http://127.0.0.1:4181"; 195 | extraConfig = { 196 | code-challenge-method = "S256"; # supported by dex and inidcated by a logged warning 197 | exclude-logging-path = "/oauth2/static/css/all.min.css,/oauth2/static/css/bulma.min.css"; 198 | oidc-issuer-url = cfgd.settings.issuer; 199 | provider-display-name = "Portunus"; 200 | # checking for groups requires next to the default scopes also the `groups` scope, otherwise all authentication tries fail 201 | scope = lib.mkIf (lib.any (x: x.allowed_groups != null) (lib.attrValues cfgo.nginx.virtualHosts)) "openid email profile groups"; 202 | }; 203 | }; 204 | 205 | portunus.dex = lib.mkIf (cfg.enable && cfg.oauth2-proxy.configure) { 206 | enable = true; 207 | oidcClients = [{ 208 | callbackURL = cfgo.redirectURL; 209 | id = cfg.oauth2-proxy.clientID; 210 | }]; 211 | }; 212 | }; 213 | 214 | security.ldap = lib.mkIf cfg.ldapPreset { 215 | domainName = cfg.domain; 216 | webDomainName = cfg.webDomain; 217 | givenNameField = "givenName"; 218 | groupFilter = group: "(&(objectclass=person)(isMemberOf=cn=${group},${ldap.roleBaseDN}))"; 219 | mailField = "mail"; 220 | port = 636; 221 | roleBaseDN = "ou=groups"; 222 | roleField = "cn"; 223 | roleFilter = "(&(objectclass=groupOfNames)(member=%s))"; 224 | roleValue = "dn"; 225 | searchFilterWithGroupFilter = userFilterGroup: userFilter: if (userFilterGroup != null) then "(&${ldap.groupFilter userFilterGroup}${userFilter})" else userFilter; 226 | sshPublicKeyField = "sshPublicKey"; 227 | searchUID = "search"; 228 | surnameField = "sn"; 229 | userField = "uid"; 230 | userFilter = replaceStr: "(|(uid=${replaceStr})(mail=${replaceStr}))"; 231 | userBaseDN = "ou=users"; 232 | }; 233 | 234 | systemd.services = lib.mkIf cfg.oauth2-proxy.configure { 235 | oauth2-proxy.requires = [ "network-online.target" ]; 236 | }; 237 | }; 238 | } 239 | -------------------------------------------------------------------------------- /modules/postgres.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, options, pkgs, utils, ... }: 2 | 3 | let 4 | opt = options.services.postgresql; 5 | cfg = config.services.postgresql; 6 | cfgu = config.services.postgresql.upgrade; 7 | latestVersion = if pkgs?postgresql_17 then "17" else "16"; 8 | mkTimerDefault = time: { 9 | OnBootSec = "10m"; 10 | OnCalendar = time; 11 | Persistent = true; 12 | RandomizedDelaySec = "10m"; 13 | }; 14 | in 15 | { 16 | options.services.postgresql = { 17 | configurePgStatStatements = libS.mkOpinionatedOption "configure and enable pg_stat_statements extension"; 18 | 19 | databases = lib.mkOption { 20 | type = lib.types.listOf lib.types.str; 21 | description = '' 22 | List of all databases. 23 | 24 | This option is used eg. when installing extensions like pg_stat_stements in all databases. 25 | 26 | ::: {.note} 27 | `services.postgresql.ensureDatabases` and `postgres` are automatically added. 28 | ::: 29 | ''; 30 | }; 31 | 32 | enableAllPreloadedLibraries = libS.mkOpinionatedOption "enable all extensions installed through `shared_preload_libraries`"; 33 | 34 | ensureUsers = lib.mkOption { 35 | type = lib.types.listOf (lib.types.submodule { 36 | options = { 37 | ensurePasswordFile = lib.mkOption { 38 | type = lib.types.nullOr lib.types.path; 39 | default = null; 40 | description = "Path to a file containing the password of the user."; 41 | }; 42 | }; 43 | }); 44 | }; 45 | 46 | pgRepackTimer = { 47 | enable = libS.mkOpinionatedOption "install pg_repack and configure a systemd timer to run it periodically on all DBs"; 48 | 49 | timerConfig = lib.mkOption { 50 | type = lib.types.nullOr (lib.types.attrsOf utils.systemdUtils.unitOptions.unitOption); 51 | default = mkTimerDefault "02:00"; 52 | example = { 53 | OnCalendar = "06:00"; 54 | Persistent = true; 55 | RandomizedDelaySec = "5h"; 56 | }; 57 | description = '' 58 | When to run the VACUUM ANALYZE. 59 | See {manpage}`systemd.timer(5)` for details. 60 | ''; 61 | }; 62 | }; 63 | 64 | preloadAllExtensions = libS.mkOpinionatedOption "load all installed extensions through `shared_preload_libraries`"; 65 | 66 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 67 | 68 | refreshCollation = libS.mkOpinionatedOption "refresh collation on startup. This prevents errors when initializing new DBs after a glibc upgrade"; 69 | 70 | upgrade = { 71 | enable = libS.mkOpinionatedOption '' 72 | install the `upgrade-postgres` script. 73 | 74 | The script can upgrade a local postgres server in a two step process. 75 | Before the upgrade can be be started, `services.postgresql.upgrade.stopServices` must be configured! 76 | After that is done and deploment, the upgrade can be started by running the script. 77 | 78 | The script first stops all services configured in `stopServices` and the postgres server and then runs a `pg_upgrade` with the configured `newPackage`. 79 | After that is complete, `services.postgresql.package` must be adjusted and deployed. 80 | As a final step it is highly recommend to run the printed `vacuumdb` command to achieve the best performance. 81 | If the upgrade is successful, the old data can be deleted by running the printed `delete_old_cluster.sh` script. 82 | 83 | ::: {.warning} 84 | It is recommended to do a backup before doing the upgrade in the form of an SQL dump of the databases. 85 | ::: 86 | ''; 87 | 88 | extraArgs = lib.mkOption { 89 | type = with lib.types; listOf str; 90 | default = [ "--link" "--jobs=$(nproc)" ]; 91 | description = "Extra arguments to pass to `pg_upgrade`. See for more information."; 92 | }; 93 | 94 | newPackage = (lib.mkPackageOption pkgs "postgresql" { 95 | default = [ "postgresql_${latestVersion}" ]; 96 | }) // { 97 | description = '' 98 | The postgres package that is being upgraded to. 99 | After running `upgrade-postgres`, `service.postgresql.packages` must be set to this exact package to successfully complete the update. 100 | ''; 101 | }; 102 | 103 | stopServices = lib.mkOption { 104 | type = with lib.types; listOf str; 105 | default = [ ]; 106 | example = [ "hedgedoc" "phpfpm-nextcloud" "nextcloud-notify_push" ]; 107 | description = '' 108 | Systemd service names which are stopped before an upgrade is started. 109 | It is very important that all postgres clients are stopped before an upgrade is attempted as they are blocking operations on the databases. 110 | 111 | The service files of some well known services are added by default. Check the source code of the module to discover which those are. 112 | 113 | ::: {.note} 114 | These can match the service name but do not need to! For example services using phpfpm might have a `phpfpm-` prefix. 115 | ::: 116 | ''; 117 | }; 118 | }; 119 | 120 | vacuumAnalyzeTimer = { 121 | enable = libS.mkOpinionatedOption "configure a systemd timer to run `VACUUM ANALYZE` periodically on all DBs"; 122 | 123 | timerConfig = lib.mkOption { 124 | type = lib.types.nullOr (lib.types.attrsOf utils.systemdUtils.unitOptions.unitOption); 125 | default = mkTimerDefault "03:00"; 126 | example = { 127 | OnCalendar = "06:00"; 128 | Persistent = true; 129 | RandomizedDelaySec = "5h"; 130 | }; 131 | description = '' 132 | When to run the VACUUM ANALYZE. 133 | See {manpage}`systemd.timer(5)` for details. 134 | ''; 135 | }; 136 | }; 137 | }; 138 | 139 | config = lib.mkIf cfg.enable { 140 | assertions = [ 141 | { 142 | assertion = cfg.refreshCollation -> lib.versionAtLeast cfg.package.version "15"; 143 | message = "services.postgresql.refreshCollation requires at least PostgreSQL version 15"; 144 | } 145 | { 146 | assertion = let 147 | # the csv type maps an empty list [] to an empty string which splitString maps to [""] ..... 148 | preload_libs = lib.splitString "," cfg.settings.shared_preload_libraries; 149 | in preload_libs == "" -> lib.all (so: so != "") preload_libs; 150 | message = "services.postgresql.settings.shared_preload_libraries cannot contain empty elements: \"${cfg.settings.shared_preload_libraries}\""; 151 | } 152 | ]; 153 | 154 | warnings = lib.optional (lib.versionOlder cfg.package.version latestVersion) 155 | "You are are running PostgreSQL version ${cfg.package.version} but the latest version is ${latestVersion}. Consider upgrading :)"; 156 | 157 | environment = { 158 | interactiveShellInit = lib.mkIf cfgu.enable '' 159 | if [[ ${cfgu.newPackage.version} != ${cfg.package.version} ]]; then 160 | echo "There is a major postgres update available! Current version: ${cfg.package.version}, Update version: ${cfgu.newPackage.version}" 161 | fi 162 | ''; 163 | 164 | systemPackages = lib.mkIf cfgu.enable [ ( 165 | let 166 | extensions = if lib.hasAttr "extensions" options.services.postgresql then "extensions" else "extraPlugins"; 167 | # conditions copied from nixos/modules/services/databases/postgresql.nix 168 | newPackage = if cfg.enableJIT then cfgu.newPackage.withJIT else cfgu.newPackage; 169 | newData = "/var/lib/postgresql/${cfgu.newPackage.psqlSchema}"; 170 | newBin = "${if cfg.${extensions} == [] then newPackage else newPackage.withPackages cfg.${extensions}}/bin"; 171 | 172 | oldPackage = if cfg.enableJIT then cfg.package.withJIT else cfg.package; 173 | oldData = config.services.postgresql.dataDir; 174 | oldBin = "${if cfg.${extensions} == [] then oldPackage else oldPackage.withPackages cfg.${extensions}}/bin"; 175 | in 176 | pkgs.writeScriptBin "upgrade-postgres" /* bash */ '' 177 | set -eu 178 | 179 | echo "Current version: ${cfg.package.version}" 180 | echo "Update version: ${cfgu.newPackage.version}" 181 | 182 | if [[ ${cfgu.newPackage.version} == ${cfg.package.version} ]]; then 183 | echo "There is no major postgres update available." 184 | exit 2 185 | fi 186 | 187 | # don't fail when any unit cannot be stopped 188 | systemctl stop ${lib.concatStringsSep " " cfgu.stopServices} || true 189 | systemctl stop postgresql 190 | 191 | install -d -m 0700 -o postgres -g postgres "${newData}" 192 | cd "${newData}" 193 | sudo -u postgres "${newBin}/initdb" -D "${newData}" 194 | 195 | sudo -u postgres "${newBin}/pg_upgrade" \ 196 | --old-datadir "${oldData}" --new-datadir "${newData}" \ 197 | --old-bindir ${oldBin} --new-bindir ${newBin} \ 198 | ${lib.concatStringsSep " " cfgu.extraArgs} \ 199 | "$@" 200 | 201 | echo " 202 | 203 | 204 | Run the below shell commands after setting this NixOS option: 205 | services.postgresql.package = pkgs.postgresql_${lib.versions.major cfgu.newPackage.version} 206 | 207 | sudo -u postgres vacuumdb --all --analyze-in-stages 208 | ${newData}/delete_old_cluster.sh 209 | " 210 | '' 211 | ) ]; 212 | }; 213 | 214 | services = { 215 | postgresql = { 216 | databases = [ "postgres" ] ++ config.services.postgresql.ensureDatabases; 217 | enableJIT = lib.mkIf cfg.recommendedDefaults true; 218 | extensions = lib.mkIf cfg.pgRepackTimer.enable (ps: with ps; [ pg_repack ]); 219 | settings.shared_preload_libraries = 220 | lib.optional cfg.configurePgStatStatements "pg_stat_statements" 221 | # TODO: upstream, this probably requires a new entry in passthru to pick if the object name doesn't match the plugin name or there are multiple 222 | # https://github.com/NixOS/nixpkgs/blob/nixos-unstable/nixos/modules/services/databases/postgresql.nix#L81 223 | ++ (let 224 | # NOTE: move into extensions passthru.libName when upstreaming and find via cfg.finalPackage.installedExtensions 225 | getSoOrFallback = so: let 226 | name = lib.getName so; 227 | in { 228 | postgis = "postgis-3"; 229 | # withJIT installs the postgres' jit output as an extension but that is no shared object to load 230 | postgresql = null; 231 | }.${name} or name; 232 | in lib.optionals cfg.preloadAllExtensions (lib.filter (x: x != null) (map getSoOrFallback cfg.finalPackage.installedExtensions))); 233 | upgrade.stopServices = with config.services; lib.mkMerge [ 234 | (lib.mkIf (atuin.enable && atuin.database.createLocally) [ "atuin" ]) 235 | (lib.mkIf (gancio.enable && gancio.settings.db.dialect == "postgres") [ "gancio" ]) 236 | (lib.mkIf (gitea.enable && gitea.database.socket == "/run/postgresql") [ "gitea" ]) 237 | (lib.mkIf (grafana.enable && grafana.settings.database.host == "/run/postgresql") [ "grafana" ]) 238 | (lib.mkIf (healthchecks.enable && healthchecks.settings.DB_HOST == "/run/postgresql") [ "healthchecks" ]) 239 | (lib.mkIf (hedgedoc.enable && hedgedoc.settings.db.host == "/run/postgresql") [ "hedgedoc" ]) 240 | # @ means to connect to localhost 241 | (lib.mkIf (home-assistant.enable && (lib.hasPrefix "postgresql://@/" home-assistant.config.recorder.db_url or "")) [ "home-assistant" ]) 242 | # if host= is omitted, hydra defaults to connect to localhost 243 | (lib.mkIf (hydra.enable && (!lib.hasInfix ";host=" hydra.dbi)) [ 244 | "hydra-evaluator" "hydra-notify" "hydra-send-stats" "hydra-update-gc-roots" "hydra-queue-runner" "hydra-server" 245 | ]) 246 | (lib.mkIf (mastodon.enable && mastodon.database.host == "/run/postgresql") [ "mastodon-sidekiq-all" "mastodon-streaming.target" "mastodon-web"]) 247 | # assume that when host is set, which is not the default, the database is none local 248 | (lib.mkIf (matrix-synapse.enable && (!lib.hasAttr "host" matrix-synapse.settings.database.args)) [ "matrix-synapse" ]) 249 | (lib.mkIf (mediawiki.enable && mediawiki.database.socket == "/run/postgresql") [ "phpfpm-mediawiki" ]) 250 | (lib.mkIf (miniflux.enable && miniflux.createDatabaseLocally) [ "miniflux" ]) 251 | # TODO: simplify after https://github.com/NixOS/nixpkgs/pull/352508 got merged 252 | (lib.mkIf (mobilizon.enable && lib.hasSuffix "/run/postgresql" mobilizon.settings.":mobilizon"."Mobilizon.Storage.Repo".socket_dir) [ "mobilizon" ]) 253 | (lib.mkIf (nextcloud.notify_push.enable && nextcloud.notify_push.dbhost == "/run/postgresql") [ "nextcloud-notify_push" ]) 254 | (lib.mkIf (nextcloud.enable && nextcloud.config.dbhost == "/run/postgresql") [ "phpfpm-nextcloud" ]) 255 | (lib.mkIf (pretalx.enable && pretalx.settings.database.host == "/run/postgresql") [ "pretalx-web" "pretalx-worker" ]) 256 | (lib.mkIf (vaultwarden.enable && (lib.hasInfix "?host=/run/postgresql" vaultwarden.config.DATABASE_URL)) [ "vaultwarden" ]) 257 | ]; 258 | }; 259 | 260 | postgresqlBackup = lib.mkIf cfg.recommendedDefaults { 261 | compression = "zstd"; 262 | compressionLevel = 9; 263 | pgdumpOptions = "--create --clean"; 264 | }; 265 | }; 266 | 267 | systemd = { 268 | services = { 269 | postgresql = { 270 | postStart = lib.mkMerge [ 271 | (lib.mkIf cfg.refreshCollation (lib.mkBefore /* bash */ '' 272 | # copied from upstream due to the lack of extensibility 273 | # TODO: improve this upstream? 274 | PSQL="psql --port=${toString cfg.settings.port}" 275 | 276 | while ! $PSQL -d postgres -c "" 2> /dev/null; do 277 | if ! kill -0 "$MAINPID"; then exit 1; fi 278 | sleep 0.1 279 | done 280 | 281 | $PSQL -tAc 'ALTER DATABASE "template1" REFRESH COLLATION VERSION' 282 | '')) 283 | 284 | (lib.concatMapStrings (user: lib.optionalString (user.ensurePasswordFile != null) /* psql */ '' 285 | $PSQL -tA <<'EOF' 286 | DO $$ 287 | DECLARE password TEXT; 288 | BEGIN 289 | password := trim(both from replace(pg_read_file('${user.ensurePasswordFile}'), E'\n', ''')); 290 | EXECUTE format('ALTER ROLE ${user.name} WITH PASSWORD '''%s''';', password); 291 | END $$; 292 | EOF 293 | '') cfg.ensureUsers) 294 | 295 | # install/update pg_stat_statements extension in all databases 296 | # based on https://git.catgirl.cloud/999eagle/dotfiles-nix/-/blob/main/modules/system/server/postgres/default.nix#L294-302 297 | (lib.mkIf (cfg.enableAllPreloadedLibraries || cfg.configurePgStatStatements) (lib.concatStrings (map (db: 298 | (lib.concatMapStringsSep "\n" (ext: let 299 | # This is ugly... 300 | ext' = lib.head (lib.splitString "-" ext); 301 | in /* bash */ '' 302 | $PSQL -tAd '${db}' -c 'CREATE EXTENSION IF NOT EXISTS "${ext'}"' 303 | $PSQL -tAd '${db}' -c '${if ext' == "postgis" then 304 | "SELECT postgis_extensions_upgrade()" 305 | else 306 | ''ALTER EXTENSION "${ext'}" UPDATE''}' 307 | '') (lib.splitString "," (if cfg.enableAllPreloadedLibraries then 308 | cfg.settings.shared_preload_libraries 309 | else if cfg.configurePgStatStatements then 310 | "pg_stat_statements" 311 | else 312 | "" 313 | )) 314 | ) 315 | ) cfg.databases))) 316 | 317 | (lib.mkIf cfg.refreshCollation (lib.concatStrings (map (db: /* bash */ '' 318 | $PSQL -tAc 'ALTER DATABASE "${db}" REFRESH COLLATION VERSION' 319 | '') cfg.databases))) 320 | ]; 321 | 322 | # reduce downtime for dependent services 323 | stopIfChanged = lib.mkIf cfg.recommendedDefaults false; 324 | }; 325 | 326 | postgresql-pg-repack = lib.mkIf cfg.vacuumAnalyzeTimer.enable { 327 | description = "Repack all PostgreSQL databases"; 328 | after = [ "postgresql.service" ]; 329 | serviceConfig = { 330 | ExecStart = "${lib.getExe cfg.package.pkgs.pg_repack} --port=${builtins.toString cfg.settings.port} --all"; 331 | User = "postgres"; 332 | }; 333 | wantedBy = [ "timers.target" ]; 334 | }; 335 | 336 | postgresql-vacuum-analyze = lib.mkIf cfg.vacuumAnalyzeTimer.enable { 337 | description = "Vacuum and analyze all PostgreSQL databases"; 338 | after = [ "postgresql.service" ]; 339 | serviceConfig = { 340 | ExecStart = "${lib.getExe' cfg.package "psql"} --port=${builtins.toString cfg.settings.port} -tAc 'VACUUM ANALYZE'"; 341 | User = "postgres"; 342 | }; 343 | wantedBy = [ "timers.target" ]; 344 | }; 345 | }; 346 | 347 | timers = let 348 | mkTimerConfig = name: lib.mkMerge [ 349 | (lib.mkDefault opt."${name}".timerConfig.default) 350 | cfg."${name}".timerConfig 351 | ]; 352 | in { 353 | postgresql-pg-repack = lib.mkIf cfg.pgRepackTimer.enable { 354 | timerConfig = mkTimerConfig "pgRepackTimer"; 355 | wantedBy = [ "timers.target" ]; 356 | }; 357 | postgresql-vacuum-analyze = lib.mkIf cfg.vacuumAnalyzeTimer.enable { 358 | timerConfig = mkTimerConfig "vacuumAnalyzeTimer"; 359 | wantedBy = [ "timers.target" ]; 360 | }; 361 | }; 362 | }; 363 | }; 364 | } 365 | -------------------------------------------------------------------------------- /modules/prometheus.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | 3 | let 4 | cfg = config.services.prometheus; 5 | cfgb = cfg.exporters.blackbox; 6 | 7 | yamlFormat = pkgs.formats.yaml { }; 8 | in 9 | { 10 | options.services.prometheus = { 11 | exporters.blackbox = { 12 | blackboxExporterURL = lib.mkOption { 13 | type = lib.types.str; 14 | example = "127.0.0.1:9115"; 15 | description = "URL under which prometheus can reach the blackbox exporter."; 16 | }; 17 | 18 | # TODO: upstream 19 | config = lib.mkOption { 20 | inherit (yamlFormat) type; 21 | default = { }; 22 | description = '' 23 | Structured configuration that is being written into blackbox' configFile option. 24 | 25 | See for upstream documentation. 26 | ''; 27 | }; 28 | 29 | dnsProbe = lib.mkOption { 30 | type = with lib.types; attrsOf (submodule { 31 | options = { 32 | domains = lib.mkOption { 33 | type = with lib.types; listOf str; 34 | example = [ "example.com" ]; 35 | description = "Query name to query"; 36 | }; 37 | 38 | targets = lib.mkOption { 39 | type = with lib.types; listOf str; 40 | default = if config.services.resolved.enable then [ "127.0.0.53" ] else lib.head config.networking.nameservers; 41 | defaultText = lib.literalExpression "if config.services.resolved.enable then [ \"127.0.0.53\" ] else lib.head config.networking.nameservers"; 42 | description = "DNS servers to test this probe against."; 43 | }; 44 | 45 | type = lib.mkOption { 46 | type = lib.types.str; 47 | example = "A"; 48 | description = "Record type to query (A, AAAA, CNAME, SOA, NS, ...)"; 49 | }; 50 | }; 51 | }); 52 | default = { }; 53 | example = '' 54 | { 55 | name = "example.com"; 56 | type = "A"; 57 | } 58 | ''; 59 | }; 60 | 61 | httpProbe = lib.mkOption { 62 | type = with lib.types; attrsOf (submodule { 63 | options = { 64 | urls = lib.mkOption { 65 | type = with lib.types; listOf str; 66 | example = [ "https://example.com" ]; 67 | description = "URL to probe"; 68 | }; 69 | 70 | ip = lib.mkOption { 71 | type = lib.types.enum [ "both" "ip4" "ip6" ]; 72 | default = "both"; 73 | example = "ip6"; 74 | description = "Whether to check the given URLs with ip4, ip6 or both."; 75 | }; 76 | 77 | statusCode = lib.mkOption { 78 | type = with lib.types; listOf ints.unsigned; 79 | example = [ 200 ]; 80 | description = "HTTP status code which is considered successful."; 81 | }; 82 | }; 83 | }); 84 | default = { }; 85 | example = '' 86 | { 87 | statusCode = [ 200 ]; 88 | url = [ "https://example.com" ]; 89 | } 90 | ''; 91 | }; 92 | }; 93 | 94 | # TODO: upstream 95 | rulesConfig = lib.mkOption { 96 | type = lib.types.listOf yamlFormat.type; 97 | default = [ ]; 98 | description = '' 99 | Structured configuration that is being written into prometheus' rules option. 100 | 101 | See and 102 | for upstream documentation. 103 | ''; 104 | }; 105 | }; 106 | 107 | config = lib.mkIf cfg.enable { 108 | services.prometheus = { 109 | exporters.blackbox = { 110 | config.modules = lib.mkMerge ( 111 | (lib.mapAttrsToList (probeName: opts: 112 | (lib.foldl (x: domain: x // { 113 | "dns_${probeName}_${domain}" = { 114 | dns = { 115 | query_name = domain; 116 | query_type = opts.type; 117 | valid_rcodes = [ "NOERROR" ]; 118 | }; 119 | prober = "dns"; 120 | timeout = "5s"; 121 | }; 122 | }) { } opts.domains) 123 | ) cfgb.dnsProbe) 124 | 125 | ++ lib.mapAttrsToList (name: opts: let 126 | setting = { 127 | "http_${name}" = { 128 | http = { 129 | ip_protocol_fallback = false; 130 | method = "GET"; 131 | follow_redirects = false; 132 | preferred_ip_protocol = "ip4"; 133 | valid_http_versions = [ 134 | "HTTP/1.1" 135 | "HTTP/2.0" 136 | ]; 137 | valid_status_codes = opts.statusCode; 138 | }; 139 | prober = "http"; 140 | timeout = "10s"; 141 | }; 142 | }; 143 | in (lib.optionalAttrs (opts.ip == "both" || opts.ip == "ip4") setting) 144 | // (lib.optionalAttrs (opts.ip == "both") { 145 | "http_${name}_ip6" = lib.recursiveUpdate setting."http_${name}" { 146 | http.preferred_ip_protocol = "ip6"; 147 | }; 148 | }) // (lib.optionalAttrs (opts.ip == "ip6") { 149 | "http_${name}" = lib.recursiveUpdate setting."http_${name}" { 150 | http.preferred_ip_protocol = "ip6"; 151 | }; 152 | }) 153 | ) cfgb.httpProbe); 154 | 155 | configFile = yamlFormat.generate "blackbox-exporter.yaml" cfgb.config; 156 | }; 157 | 158 | ruleFiles = map (rule: yamlFormat.generate "prometheus-rule" rule) cfg.rulesConfig; 159 | 160 | scrapeConfigs = let 161 | commonProbeScrapeConfig = { 162 | metrics_path = "/probe"; 163 | relabel_configs = [ { 164 | source_labels = [ "__address__" ]; 165 | target_label = "__param_target"; # __param_* will be rewritten as query string 166 | } { 167 | source_labels = [ "__param_target" ]; 168 | target_label = "instance"; 169 | } { 170 | # needed because blackbox exporter (ab)uses targets for its targets but we actually need to ask the exporter about the target state 171 | target_label = "__address__"; 172 | replacement = cfgb.blackboxExporterURL; 173 | } ]; 174 | }; 175 | 176 | genHttpProbeScrapeConfig = { name, opts }: commonProbeScrapeConfig // { 177 | job_name = "blackbox_http_${name}"; 178 | params.module = [ "http_${name}" ]; 179 | relabel_configs = commonProbeScrapeConfig.relabel_configs ++ [ { 180 | source_labels = [ "__param_target" ]; 181 | regex = "https?://(.*)"; 182 | target_label = "domain"; 183 | } ]; 184 | static_configs = [ { 185 | targets = opts.urls; 186 | } ]; 187 | }; 188 | in lib.flatten (lib.foldl (x: probe: x ++ [ 189 | (lib.foldl (x: domain: x ++ [ 190 | (commonProbeScrapeConfig // { 191 | job_name = "blackbox_dns_${probe.name}_${domain}"; 192 | params.module = [ "dns_${probe.name}_${domain}" ]; 193 | static_configs = [ { 194 | labels = { inherit domain; }; 195 | inherit (probe.value) targets; 196 | } ]; 197 | }) 198 | ]) [ ] probe.value.domains) 199 | ]) [ ] (lib.attrsToList cfgb.dnsProbe)) 200 | 201 | ++ lib.filter (v: v != null) (lib.mapAttrsToList (name: opts: 202 | if (opts.ip == "both" || opts.ip == "ip4") then (genHttpProbeScrapeConfig { inherit name opts; }) else null 203 | ) cfgb.httpProbe 204 | ++ lib.mapAttrsToList (name: opts: 205 | if (opts.ip == "both") then (genHttpProbeScrapeConfig { inherit name opts; } // { 206 | job_name = "blackbox_http_${name}_ip6"; 207 | params.module = [ "http_${name}_ip6" ]; 208 | }) else if (opts.ip == "ip6") then (genHttpProbeScrapeConfig { inherit name opts; } // { 209 | job_name = "blackbox_http_${name}"; 210 | params.module = [ "http_${name}" ]; 211 | }) else null 212 | ) cfgb.httpProbe); 213 | }; 214 | }; 215 | } 216 | -------------------------------------------------------------------------------- /modules/renovate.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, options, ... }: 2 | 3 | let 4 | cfg = config.services.renovate; 5 | in 6 | { 7 | options.services.renovate = { 8 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 9 | }; 10 | 11 | config = lib.mkIf (cfg?enable && cfg.enable) { 12 | services = lib.optionalAttrs (options.services.renovate?settings) { 13 | renovate.settings = { 14 | cachePrivatePackages = true; 15 | configMigration = true; 16 | optimizeForDisabled = true; 17 | persistRepoData = true; 18 | repositoryCache = "enabled"; 19 | }; 20 | }; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /modules/simd.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.simd; 5 | in 6 | { 7 | options.simd = { 8 | enable = lib.mkEnableOption "optimized builds with simd instructions. This is an experimental option meant to be used for testing, as it invlidated the entire cache"; 9 | arch = lib.mkOption { 10 | type = with lib.types; nullOr str; 11 | default = null; 12 | description = '' 13 | Microarchitecture string for nixpkgs.hostPlatform.gcc.march and to generate system-features. 14 | Can be determined with: ``nix --extra-experimental-features "flakes nix-command" shell nixpkgs#gcc -c gcc -march=native -Q --help=target | grep march`` 15 | ''; 16 | }; 17 | }; 18 | 19 | config = { 20 | nix.settings.system-features = lib.mkIf (cfg.arch != null) (libS.nix.gcc-system-features cfg.arch); 21 | 22 | nixpkgs.hostPlatform = lib.mkIf cfg.enable { 23 | gcc.arch = cfg.arch; 24 | inherit (config.nixpkgs) system; 25 | }; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /modules/slim.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.slim; 5 | in 6 | { 7 | options.slim = { 8 | enable = libS.mkOpinionatedOption "disable some normally rarely used things to slim down the system"; 9 | }; 10 | 11 | config = lib.mkIf cfg.enable { 12 | documentation = { 13 | # html docs and info are not required, man pages are enough 14 | doc.enable = false; 15 | info.enable = false; 16 | }; 17 | 18 | environment.defaultPackages = lib.mkForce [ ]; 19 | 20 | programs.thunderbird.package = pkgs.thunderbird.override { cfg.speechSynthesisSupport = false; }; 21 | 22 | # during testing only 550K-650K of the tmpfs where used 23 | security.wrapperDirSize = "10M"; 24 | 25 | services = { 26 | orca.enable = false; # requires speechd 27 | speechd.enable = false; # voice files are big and fat 28 | }; 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /modules/ssh.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfgP = config.programs.ssh; 5 | cfgS = config.services.openssh; 6 | 7 | sshKeygen = lib.getExe' cfgP.package "ssh-keygen"; 8 | in 9 | { 10 | options = { 11 | programs.ssh = { 12 | addPopularKnownHosts = libS.mkOpinionatedOption "add ssh public keys of popular websites to known_hosts"; 13 | addHelpOnHostkeyMismatch = libS.mkOpinionatedOption "show a ssh-keygen command to remove mismatching ssh knownhosts entries"; 14 | recommendedDefaults = libS.mkOpinionatedOption "set recommend and secure default settings"; 15 | }; 16 | 17 | services.openssh = { 18 | fixPermissions = libS.mkOpinionatedOption "fix host key permissions to prevent lock outs"; 19 | recommendedDefaults = libS.mkOpinionatedOption "set recommend and secure default settings"; 20 | regenerateWeakRSAHostKey = libS.mkOpinionatedOption "regenerate weak (less than 4096 bits) RSA host keys."; 21 | }; 22 | }; 23 | 24 | config = { 25 | # TODO: there is no programs.ssh.enable, what to use instead? 26 | programs.ssh = { 27 | extraConfig = lib.mkIf cfgP.recommendedDefaults /* sshconfig */ '' 28 | # hard complain about wrong knownHosts 29 | StrictHostKeyChecking accept-new 30 | # make automated host key rotation possible 31 | UpdateHostKeys yes 32 | # fetch host keys via DNS and trust them 33 | VerifyHostKeyDNS yes 34 | ''; 35 | knownHosts = lib.mkIf cfgP.addPopularKnownHosts (lib.mkMerge [ 36 | (libS.mkPubKey "github.com" "ssh-rsa" "AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=") 37 | (libS.mkPubKey "github.com" "ecdsa-sha2-nistp256" "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=") 38 | (libS.mkPubKey "github.com" "ssh-ed25519" "AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl") 39 | (libS.mkPubKey "gitlab.com" "ssh-rsa" "AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9") 40 | (libS.mkPubKey "gitlab.com" "ecdsa-sha2-nistp256" "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=") 41 | (libS.mkPubKey "gitlab.com" "ssh-ed25519" "AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf") 42 | (libS.mkPubKey "git.openwrt.org" "ssh-rsa" "AAAAB3NzaC1yc2EAAAABIwAAAQEAtnM1w/A1uRZqZuYHhw4ASOe9mr3J2qKAa9K9zR8jG+B+NQVtYlIBSkmCFyP6OuydCmoRZ5Gs1I9pl/hEyi7ieEi6g9yww/JbV322cw04Tli46enIYDG1bnSxF6Qt4aXqvPhcObI3z/1Z3XR6weS1fiLDzLvzq+w1gNM77xExD4Mh27LTPkdwOWjkGa5joNx3EQUC3rzwxUqE4fhOT2Ii93h8FSAUXY9C32jkJj9x7vfaJEsCacs6YTiUKKxyzEB+TvFZdUtGtoRThX7UVICUCD2th/r3UeSp8ItWPg/KqzSg2pRfWeYszlVoD59JZ6YCupSjjRqZddghQc94Hev7oQ==") 43 | (libS.mkPubKey "git.openwrt.org" "ecdsa-sha2-nistp256" "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBASOHg+tghASiZF0ClxYb/HEhUcqnD43I86YatRZSUsXNWLEd8yOzjOJExDHHaKtmZtQ/jfEMmoYbCjdEDOYm5g=") 44 | (libS.mkPubKey "git.openwrt.org" "ssh-ed25519" "AAAAC3NzaC1lZDI1NTE5AAAAIJZFpKQMaLM8bG9lAPfEpTBExrzuiTKMni7PgktmDbJe") 45 | (libS.mkPubKey "git.sr.ht" "ssh-rsa" "AAAAB3NzaC1yc2EAAAADAQABAAABAQDZ+l/lvYmaeOAPeijHL8d4794Am0MOvmXPyvHTtrqvgmvCJB8pen/qkQX2S1fgl9VkMGSNxbp7NF7HmKgs5ajTGV9mB5A5zq+161lcp5+f1qmn3Dp1MWKp/AzejWXKW+dwPBd3kkudDBA1fa3uK6g1gK5nLw3qcuv/V4emX9zv3P2ZNlq9XRvBxGY2KzaCyCXVkL48RVTTJJnYbVdRuq8/jQkDRA8lHvGvKI+jqnljmZi2aIrK9OGT2gkCtfyTw2GvNDV6aZ0bEza7nDLU/I+xmByAOO79R1Uk4EYCvSc1WXDZqhiuO2sZRmVxa0pQSBDn1DB3rpvqPYW+UvKB3SOz") 46 | (libS.mkPubKey "git.sr.ht" "ecdsa-sha2-nistp256" "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCj6y+cJlqK3BHZRLZuM+KP2zGPrh4H66DacfliU1E2DHAd1GGwF4g1jwu3L8gOZUTIvUptqWTkmglpYhFp4Iy4=") 47 | (libS.mkPubKey "git.sr.ht" "ssh-ed25519" "AAAAC3NzaC1lZDI1NTE5AAAAIMZvRd4EtM7R+IHVMWmDkVU3VLQTSwQDSAvW0t2Tkj60") 48 | ]); 49 | package = lib.mkIf cfgP.addHelpOnHostkeyMismatch (pkgs.openssh.overrideAttrs ({ patches, ... }: { 50 | patches = patches ++ [ 51 | (pkgs.fetchpatch { 52 | urls = 53 | let 54 | version = "1%259.9p1-1"; 55 | in 56 | [ 57 | "https://salsa.debian.org/ssh-team/openssh/-/raw/debian/${version}/debian/patches/mention-ssh-keygen-on-keychange.patch" 58 | ]; 59 | hash = "sha256-OZPOHwQkclUAjG3ShfYX66sbW2ahXPgsV6XNfzl9SIg="; 60 | }) 61 | ]; 62 | 63 | # takes to long and unstable requires openssh to work to advance 64 | doCheck = false; 65 | })); 66 | }; 67 | 68 | services.openssh = lib.mkIf cfgS.recommendedDefaults { 69 | settings = { 70 | # following ssh-audit: nixos default minus 2048 bit modules (diffie-hellman-group-exchange-sha256) 71 | KexAlgorithms = [ 72 | "mlkem768x25519-sha256" 73 | "sntrup761x25519-sha512" 74 | "sntrup761x25519-sha512@openssh.com" 75 | "curve25519-sha256" 76 | "curve25519-sha256@libssh.org" 77 | ]; 78 | # following ssh-audit: nixos defaults minus encrypt-and-MAC 79 | Macs = [ 80 | "hmac-sha2-512-etm@openssh.com" 81 | "hmac-sha2-256-etm@openssh.com" 82 | "umac-128-etm@openssh.com" 83 | ]; 84 | RequiredRSASize = 4095; 85 | }; 86 | }; 87 | 88 | system.activationScripts.regenerateWeakRSAHostKey = lib.mkIf cfgS.regenerateWeakRSAHostKey /* bash */ '' 89 | if [[ -e /etc/ssh/ssh_host_rsa_key && $(${sshKeygen} -l -f /etc/ssh/ssh_host_rsa_key | ${lib.getExe pkgs.gawk} '{print $1}') != 4096 ]]; then 90 | echo "Regenerating OpenSSH RSA hostkey that had less than 4096 bits..." 91 | rm -f /etc/ssh/ssh_host_rsa_key /etc/ssh/ssh_host_rsa_key.pub 92 | ${sshKeygen} -t rsa -b 4096 -N "" -f /etc/ssh/ssh_host_rsa_key 93 | fi 94 | ''; 95 | 96 | systemd.tmpfiles.rules = lib.mkIf (cfgS.enable && cfgS.fixPermissions) [ 97 | "d /etc 0755 root root -" 98 | "d /etc/ssh 0755 root root -" 99 | "f /etc/ssh/ssh_host_ed25519_key 0700 root root -" 100 | "f /etc/ssh/ssh_host_ed25519_key.pub 0744 root root -" 101 | "f /etc/ssh/ssh_host_rsa_key 0700 root root -" 102 | "f /etc/ssh/ssh_host_rsa_key.pub 0744 root root -" 103 | ]; 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /modules/strace.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, ... }: 2 | 3 | let 4 | cfg = config.programs.strace; 5 | in 6 | { 7 | options = { 8 | programs.strace = { 9 | withColors = lib.mkEnableOption "strace with colors patch"; 10 | }; 11 | }; 12 | 13 | config = lib.mkIf cfg.withColors { 14 | environment.systemPackages = [ 15 | (pkgs.strace.overrideAttrs ({ patches ? [ ], ... }: { 16 | patches = patches ++ [ 17 | (let 18 | version = "6.3"; 19 | in pkgs.fetchpatch { 20 | url = "https://github.com/xfgusta/strace-with-colors/raw/v${version}-1/strace-with-colors.patch"; 21 | name = "strace-with-colors-${version}.patch"; 22 | hash = "sha256-gcQldGsRgvGnrDX0zqcLTpEpchNEbCUFdKyii0wetEI="; 23 | }) 24 | ]; 25 | })) 26 | ]; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /modules/tmux.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.programs.tmux; 5 | in 6 | { 7 | options = { 8 | programs.tmux.recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 9 | }; 10 | 11 | config = lib.mkIf cfg.recommendedDefaults { 12 | programs.tmux = { 13 | aggressiveResize = true; 14 | baseIndex = 1; 15 | clock24 = true; 16 | escapeTime = 100; 17 | historyLimit = 50000; 18 | terminal = "xterm-256color"; 19 | extraConfig = /* tmux */'' 20 | # focus events enabled for terminals that support them 21 | set -g focus-events on 22 | 23 | # mouse control 24 | set -g mouse on 25 | 26 | # open new tab in PWD 27 | bind '"' split-window -c "#{pane_current_path}" 28 | bind % split-window -h -c "#{pane_current_path}" 29 | bind c new-window -c "#{pane_current_path}" 30 | ''; 31 | }; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /modules/users.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: 2 | 3 | { 4 | options = { 5 | users.showFailedUnitsOnLogin = lib.mkEnableOption "show failed systemd units on interactive login"; 6 | }; 7 | 8 | config = lib.mkIf config.users.showFailedUnitsOnLogin { 9 | environment.interactiveShellInit = /* sh */ '' 10 | # raise some awareness towards failed services 11 | systemctl --no-pager --failed || true 12 | ''; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /modules/vaultwarden.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.services.vaultwarden; 5 | usingPostgres = cfg.dbBackend == "postgresql"; 6 | in 7 | { 8 | options = { 9 | services.vaultwarden = { 10 | configureNginx = libS.mkOpinionatedOption "configure nginx for the configured domain"; 11 | 12 | domain = lib.mkOption { 13 | type = with lib.types; nullOr str; 14 | default = null; 15 | description = "The domain under which vaultwarden will be reachable."; 16 | }; 17 | 18 | recommendedDefaults = libS.mkOpinionatedOption "set recommended default settings"; 19 | }; 20 | }; 21 | 22 | config = lib.mkIf cfg.enable { 23 | assertions = [{ 24 | assertion = cfg.configureNginx -> cfg.domain != null; 25 | message = '' 26 | Setting services.vaultwarden.configureNginx to true requires configuring services.vaultwarden.domain! 27 | ''; 28 | }]; 29 | 30 | services = { 31 | nginx = lib.mkIf cfg.configureNginx { 32 | upstreams.vaultwarden.servers."127.0.0.1:${toString cfg.config.ROCKET_PORT}" = { }; 33 | virtualHosts.${cfg.domain}.locations = { 34 | "/".proxyPass = "http://vaultwarden"; 35 | "= /notifications/hub" = { 36 | proxyPass = "http://vaultwarden"; 37 | proxyWebsockets = true; 38 | }; 39 | }; 40 | }; 41 | 42 | postgresql = lib.mkIf usingPostgres { 43 | enable = true; 44 | ensureDatabases = [ "vaultwarden" ]; 45 | ensureUsers = [{ 46 | name = "vaultwarden"; 47 | ensureDBOwnership = true; 48 | }]; 49 | }; 50 | 51 | vaultwarden.config = lib.mkMerge [ 52 | { 53 | DATABASE_URL = lib.mkIf usingPostgres "postgresql:///vaultwarden?host=/run/postgresql"; 54 | DOMAIN = lib.mkIf (cfg.domain != null) "https://${cfg.domain}"; 55 | } 56 | (lib.mkIf cfg.recommendedDefaults { 57 | DATA_FOLDER = "/var/lib/vaultwarden"; # changes data directory 58 | ENABLE_WEBSOCKET = true; 59 | LOG_LEVEL = "warn"; 60 | PASSWORD_ITERATIONS = 600000; 61 | ROCKET_ADDRESS = "127.0.0.1"; 62 | ROCKET_PORT = lib.mkDefault 8222; 63 | SIGNUPS_VERIFY = true; 64 | TRASH_AUTO_DELETE_DAYS = 30; 65 | }) 66 | ]; 67 | }; 68 | 69 | systemd.services.vaultwarden = { 70 | after = lib.mkIf usingPostgres [ "postgresql.service" ]; 71 | requires = lib.mkIf usingPostgres [ "postgresql.service" ]; 72 | serviceConfig = lib.mkIf cfg.recommendedDefaults { 73 | StateDirectory = lib.mkForce "vaultwarden"; # modules defaults to bitwarden_rs 74 | }; 75 | }; 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /modules/vim.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, ... }: 2 | 3 | let 4 | cfg = config.programs.vim; 5 | in 6 | { 7 | options = { 8 | programs.vim = { 9 | undofile = libS.mkOpinionatedOption "configure undofile to save undo/redo across editor re-opens"; 10 | rememberCursorPosition = libS.mkOpinionatedOption "remember the last cursor position and re-open the file at that point the next time it is open"; 11 | }; 12 | }; 13 | 14 | config = lib.mkIf cfg.enable { 15 | environment.etc = lib.mkIf (cfg.undofile || cfg.rememberCursorPosition) { 16 | "vim/vimrc".text = lib.optionalString cfg.undofile /* vim */ '' 17 | set undofile " save undo file after quit 18 | set undodir=$HOME/.vim/undo " undo files location 19 | set undolevels=1000 " number of steps to save 20 | set undoreload=10000 " number of lines to save 21 | 22 | set viminfo+=n~/.vim/viminfo " move viminfo for better file completion 23 | set viminfo^=<1000 " keep more entries 24 | 25 | '' + lib.optionalString cfg.rememberCursorPosition /* vim */ '' 26 | " remember cursor position 27 | augroup JumpBack 28 | au! 29 | au BufReadPost * if line("'\"") > 1 && line("'\"") <= line("$") | exe "normal! g'\"" | endif 30 | augroup END 31 | 32 | ''; 33 | }; 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /modules/zfs.nix: -------------------------------------------------------------------------------- 1 | { config, lib, libS, pkgs, ... }: 2 | 3 | let 4 | cfg = config.boot.zfs; 5 | in 6 | { 7 | options = { 8 | boot.zfs = { 9 | recommendedDefaults = libS.mkOpinionatedOption "enable recommended ZFS settings"; 10 | }; 11 | }; 12 | 13 | imports = [ 14 | (lib.mkRemovedOptionModule ["boot" "zfs" "latestCompatibleKernel"] '' 15 | latestCompatibleKernel has been removed because zfs.passthru.latestCompatibleLinuxPackages has been effectively removed. 16 | Consider using instead. 17 | '') 18 | ]; 19 | 20 | config = lib.mkIf cfg.enabled { 21 | services.zfs = lib.mkIf cfg.recommendedDefaults { 22 | autoScrub.enable = true; 23 | trim.enable = true; 24 | }; 25 | 26 | virtualisation.containers.storage.settings = lib.mkIf cfg.recommendedDefaults { 27 | # fixes: Error: 'overlay' is not supported over zfs, a mount_program is required: backing file system is unsupported for this graph driver 28 | storage.options.mount_program = lib.getExe pkgs.fuse-overlayfs; 29 | }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /tests/default.nix: -------------------------------------------------------------------------------- 1 | { lib, self, system }: 2 | 3 | let 4 | mkTest = { module ? { } }: lib.nixosSystem { 5 | modules = [ 6 | self.nixosModule 7 | # include a very basic config which contains fileSystem, etc. to avoid many assertions 8 | ({ modulesPath, ... }: { 9 | imports = lib.singleton "${modulesPath}/installer/cd-dvd/installation-cd-minimal.nix"; 10 | }) 11 | module 12 | ]; 13 | inherit system; 14 | }; 15 | in 16 | lib.mapAttrs (name: value: let 17 | inherit (value.config.system.build) toplevel; 18 | in toplevel // { 19 | inherit (value) config options; 20 | })({ 21 | no-config = mkTest { }; 22 | 23 | # https://github.com/NuschtOS/nixos-modules/issues/2 24 | acme-staging = mkTest { 25 | module = { 26 | security.acme.staging = true; 27 | }; 28 | }; 29 | 30 | # https://github.com/NuschtOS/nixos-modules/issues/39 31 | hound-repos = mkTest { 32 | module = { 33 | services.hound = { 34 | enable = true; 35 | repos = [ "https://github.com/NuschtOS/nixos-modules.git" ]; 36 | }; 37 | }; 38 | }; 39 | 40 | grafana-no-nginx = mkTest { 41 | module = { 42 | services.grafana = { 43 | enable = true; 44 | settings.security = { 45 | admin_password = "secure"; 46 | secret_key = "secure"; 47 | }; 48 | }; 49 | }; 50 | }; 51 | 52 | matrix-nginx-with-socket = mkTest { 53 | module = { 54 | services = { 55 | matrix-synapse = { 56 | enable = true; 57 | domain = "example.org"; 58 | listenOnSocket = true; 59 | element-web = { 60 | enable = true; 61 | domain = "example.org"; 62 | }; 63 | }; 64 | 65 | nginx.enable = true; 66 | }; 67 | }; 68 | }; 69 | 70 | matrix-no-nginx = mkTest { 71 | module = { 72 | services.matrix-synapse = { 73 | enable = true; 74 | domain = "example.org"; 75 | element-web = { 76 | enable = true; 77 | domain = "example.org"; 78 | }; 79 | }; 80 | }; 81 | }; 82 | 83 | matrix-no-element = mkTest { 84 | module = { 85 | services = { 86 | matrix-synapse = { 87 | enable = true; 88 | domain = "example.org"; 89 | }; 90 | 91 | nginx.enable = true; 92 | }; 93 | }; 94 | }; 95 | 96 | # https://github.com/NuschtOS/nixos-modules/issues/160 97 | matrix-element-same-domain = mkTest { 98 | module = { 99 | services = { 100 | matrix-synapse = { 101 | enable = true; 102 | domain = "example.org"; 103 | element-web = { 104 | enable = true; 105 | domain = "example.org"; 106 | }; 107 | }; 108 | 109 | nginx.enable = true; 110 | }; 111 | }; 112 | }; 113 | 114 | nextcloud-plain = mkTest { 115 | module = { 116 | services.nextcloud = { 117 | enable = true; 118 | config.dbtype = "pgsql"; 119 | config.adminpassFile = "/password"; 120 | hostName = "example.com"; 121 | }; 122 | }; 123 | }; 124 | 125 | postgresql-plain = mkTest { 126 | module = { 127 | services.postgresql.enable = true; 128 | }; 129 | }; 130 | 131 | postgresql-load-extensions = mkTest { 132 | module = { 133 | services.postgresql = { 134 | enable = true; 135 | configurePgStatStatements = true; 136 | enableAllPreloadedLibraries = true; 137 | preloadAllExtensions = true; 138 | }; 139 | }; 140 | }; 141 | 142 | # https://github.com/NuschtOS/nixos-modules/issues/156 143 | renovate-plain = mkTest { 144 | module = { 145 | services.renovate = { 146 | enable = true; 147 | }; 148 | }; 149 | }; 150 | 151 | vaultwarden-no-nginx = mkTest { 152 | module = { 153 | services.vaultwarden = { 154 | enable = true; 155 | domain = "example.com"; 156 | }; 157 | }; 158 | }; 159 | }) 160 | --------------------------------------------------------------------------------