Repository: ibizaman/selfhostblocks Branch: main Commit: bba09dc607cf Files: 180 Total size: 1.7 MB Directory structure: gitextract_ff0ttxqq/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── auto-merge.yaml │ ├── build.yaml │ ├── demo.yml │ ├── format.yaml │ ├── lock-update.yaml │ ├── pages.yml │ └── version.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── VERSION ├── demo/ │ ├── homeassistant/ │ │ ├── README.md │ │ ├── configuration.nix │ │ ├── flake.nix │ │ ├── hardware-configuration.nix │ │ ├── keys.txt │ │ ├── secrets.yaml │ │ ├── sops.yaml │ │ ├── ssh_config │ │ ├── sshkey │ │ └── sshkey.pub │ ├── minimal/ │ │ └── flake.nix │ └── nextcloud/ │ ├── README.md │ ├── configuration.nix │ ├── flake.nix │ ├── hardware-configuration.nix │ ├── keys.txt │ ├── secrets.yaml │ ├── sops.yaml │ ├── ssh_config │ ├── sshkey │ └── sshkey.pub ├── docs/ │ ├── blocks.md │ ├── contracts.md │ ├── contributing.md │ ├── default.nix │ ├── demos.md │ ├── generate-redirects-nixos-render-docs.py │ ├── manual.md │ ├── options.md │ ├── preface.md │ ├── recipes/ │ │ ├── dnsServer.md │ │ ├── exposeService.md │ │ └── serveStaticPages.md │ ├── recipes.md │ ├── redirects.json │ ├── service-implementation-guide.md │ ├── services.md │ └── usage.md ├── flake.nix ├── lib/ │ ├── default.nix │ ├── homepage.nix │ └── module.nix ├── modules/ │ ├── blocks/ │ │ ├── authelia/ │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── authelia.nix │ │ ├── backup/ │ │ │ └── dashboard/ │ │ │ └── Backups.json │ │ ├── borgbackup/ │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── borgbackup.nix │ │ ├── davfs.nix │ │ ├── hardcodedsecret.nix │ │ ├── lldap/ │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── lldap.nix │ │ ├── mitmdump/ │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── mitmdump.nix │ │ ├── monitoring/ │ │ │ ├── dashboards/ │ │ │ │ ├── Errors.json │ │ │ │ ├── Health.json │ │ │ │ ├── Performance.json │ │ │ │ └── Scraping_Jobs.json │ │ │ ├── docs/ │ │ │ │ └── default.md │ │ │ └── rules.json │ │ ├── monitoring.nix │ │ ├── nginx/ │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── nginx.nix │ │ ├── postgresql/ │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── postgresql.nix │ │ ├── restic/ │ │ │ ├── docs/ │ │ │ │ └── default.md │ │ │ └── dummyModule.nix │ │ ├── restic.nix │ │ ├── sops/ │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── sops.nix │ │ ├── ssl/ │ │ │ ├── dashboard/ │ │ │ │ └── SSL.json │ │ │ └── docs/ │ │ │ └── default.md │ │ ├── ssl.nix │ │ ├── tinyproxy.nix │ │ ├── vpn.nix │ │ └── zfs.nix │ ├── contracts/ │ │ ├── backup/ │ │ │ ├── docs/ │ │ │ │ └── default.md │ │ │ ├── dummyModule.nix │ │ │ └── test.nix │ │ ├── backup.nix │ │ ├── dashboard/ │ │ │ ├── docs/ │ │ │ │ └── default.md │ │ │ └── dummyModule.nix │ │ ├── dashboard.nix │ │ ├── databasebackup/ │ │ │ ├── docs/ │ │ │ │ └── default.md │ │ │ ├── dummyModule.nix │ │ │ └── test.nix │ │ ├── databasebackup.nix │ │ ├── default.nix │ │ ├── mount.nix │ │ ├── secret/ │ │ │ ├── docs/ │ │ │ │ └── default.md │ │ │ ├── dummyModule.nix │ │ │ └── test.nix │ │ ├── secret.nix │ │ ├── ssl/ │ │ │ ├── docs/ │ │ │ │ └── default.md │ │ │ └── dummyModule.nix │ │ └── ssl.nix │ └── services/ │ ├── arr/ │ │ └── docs/ │ │ └── default.md │ ├── arr.nix │ ├── audiobookshelf/ │ │ └── docs/ │ │ └── default.md │ ├── audiobookshelf.nix │ ├── deluge/ │ │ └── dashboard/ │ │ └── Torrents.json │ ├── deluge.nix │ ├── firefly-iii/ │ │ └── docs/ │ │ └── default.md │ ├── firefly-iii.nix │ ├── forgejo/ │ │ └── docs/ │ │ └── default.md │ ├── forgejo.nix │ ├── grocy.nix │ ├── hledger.nix │ ├── home-assistant/ │ │ └── docs/ │ │ └── default.md │ ├── home-assistant.nix │ ├── homepage/ │ │ └── docs/ │ │ └── default.md │ ├── homepage.nix │ ├── immich.nix │ ├── jellyfin/ │ │ └── docs/ │ │ └── default.md │ ├── jellyfin.nix │ ├── karakeep/ │ │ └── docs/ │ │ └── default.md │ ├── karakeep.nix │ ├── mailserver/ │ │ └── docs/ │ │ └── default.md │ ├── mailserver.nix │ ├── nextcloud-server/ │ │ ├── dashboard/ │ │ │ └── Nextcloud.json │ │ └── docs/ │ │ └── default.md │ ├── nextcloud-server.nix │ ├── open-webui/ │ │ └── docs/ │ │ └── default.md │ ├── open-webui.nix │ ├── paperless.nix │ ├── pinchflat/ │ │ └── docs/ │ │ └── default.md │ ├── pinchflat.nix │ ├── vaultwarden/ │ │ └── docs/ │ │ └── default.md │ └── vaultwarden.nix ├── patches/ │ ├── 0001-nixos-borgbackup-add-option-to-override-state-direct.patch │ ├── 0001-selfhostblocks-never-onboard.patch │ ├── lldap.patch │ └── nextcloudexternalstorage.patch └── test/ ├── blocks/ │ ├── authelia.nix │ ├── borgbackup.nix │ ├── keypair.pem │ ├── lib.nix │ ├── lldap.nix │ ├── mitmdump.nix │ ├── monitoring.nix │ ├── postgresql.nix │ ├── restic.nix │ └── ssl.nix ├── common.nix ├── contracts/ │ ├── backup.nix │ ├── databasebackup.nix │ ├── secret/ │ │ └── sops.yaml │ └── secret.nix ├── modules/ │ ├── davfs.nix │ ├── homepage.nix │ └── lib.nix └── services/ ├── arr.nix ├── audiobookshelf.nix ├── deluge.nix ├── firefly-iii.nix ├── forgejo.nix ├── grocy.nix ├── hledger.nix ├── home-assistant.nix ├── homepage.nix ├── immich.nix ├── jellyfin.nix ├── karakeep.nix ├── nextcloud.nix ├── open-webui.nix ├── paperless.nix ├── pinchflat.nix └── vaultwarden.nix ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" reviewers: - ibizaman ================================================ FILE: .github/workflows/auto-merge.yaml ================================================ name: Auto Merge on: # Try enabling auto-merge for a pull request when a draft is marked as “ready for review”, when # a required label is applied or when a “do not merge” label is removed, or when a pull request # is updated in any way (opened, synchronized, reopened, edited). pull_request_target: types: - opened - synchronize - reopened - edited - labeled - unlabeled - ready_for_review # Try enabling auto-merge for the specified pull request or all open pull requests if none is # specified. workflow_dispatch: inputs: pull-request: description: Pull Request Number required: false jobs: automerge: runs-on: ubuntu-latest steps: - uses: reitermarkus/automerge@v2 with: token: ${{ secrets.GH_TOKEN_FOR_UPDATES }} merge-method: rebase do-not-merge-labels: never-merge required-labels: automerge pull-request: ${{ github.event.inputs.pull-request }} review: ${{ github.event.inputs.review }} dry-run: false ================================================ FILE: .github/workflows/build.yaml ================================================ # name: build # on: push # jobs: # checks: # uses: nixbuild/nixbuild-action/.github/workflows/ci-workflow.yml@v19 # with: # nix_conf: | # allow-import-from-derivation = true # secrets: # nixbuild_token: ${{ secrets.nixbuild_token }} name: "build" on: pull_request: push: branches: [ "main" ] jobs: path-filter: runs-on: ubuntu-latest outputs: changed: ${{ steps.filter.outputs.any_changed }} steps: - name: Checkout repository uses: actions/checkout@v6 - uses: tj-actions/changed-files@v47 id: filter with: files: | lib/** modules/** !modules/**/docs/** test/** flake.lock flake.nix .github/workflows/build.yaml separator: "\n" - env: ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_changed_files }} run: | echo $ALL_CHANGED_FILES build-matrix: needs: [ "path-filter" ] if: needs.path-filter.outputs.changed == 'true' runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} enable_kvm: true extra_nix_config: | keep-outputs = true keep-failed = true - name: Setup Caching uses: cachix/cachix-action@v17 with: name: selfhostblocks authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - name: Generate Matrix id: generate-matrix run: | set -euox pipefail nix flake show --allow-import-from-derivation --json \ | jq -c '.["checks"]["x86_64-linux"] | keys' > .output cat .output echo dynamic_list="$(cat .output)" >> "$GITHUB_OUTPUT" outputs: check: ${{ steps.generate-matrix.outputs.dynamic_list }} manual: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} enable_kvm: true extra_nix_config: | keep-outputs = true keep-failed = true - name: Setup Caching uses: cachix/cachix-action@v17 with: name: selfhostblocks authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - name: Build run: | nix \ --print-build-logs \ --option keep-going true \ --show-trace \ build .#manualHtml tests: runs-on: ubuntu-latest needs: [ "build-matrix" ] strategy: fail-fast: false matrix: check: ${{ fromJson(needs.build-matrix.outputs.check) }} steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} enable_kvm: true extra_nix_config: | keep-outputs = true keep-failed = true - name: Setup Caching uses: cachix/cachix-action@v17 with: name: selfhostblocks authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - name: Build run: | echo "resultPath=$(nix eval .#checks.x86_64-linux.${{ matrix.check }} --raw)" >> $GITHUB_ENV nix build --print-build-logs --show-trace .#checks.x86_64-linux.${{ matrix.check }} - name: Upload Build Result uses: actions/upload-artifact@v7 if: always() && startsWith(matrix.check, 'vm_') with: name: ${{ matrix.check }} path: ${{ env.resultPath }}/trace/* overwrite: true if-no-files-found: ignore results: name: Final Results runs-on: ubuntu-latest needs: [ manual, tests ] if: '!cancelled()' steps: - run: | result="${{ needs.manual.result }}" if ! [[ $result == "success" || $result == "skipped" ]]; then exit 1 fi result="${{ needs.tests.result }}" if ! [[ $result == "success" || $result == "skipped" ]]; then exit 1 fi exit 0 ================================================ FILE: .github/workflows/demo.yml ================================================ name: Demo on: workflow_dispatch: pull_request: push: branches: - main jobs: path-filter: runs-on: ubuntu-latest outputs: changed: ${{ steps.filter.outputs.any_changed }} steps: - name: Checkout repository uses: actions/checkout@v6 - uses: tj-actions/changed-files@v47 id: filter with: files: | demo/** lib/** modules/** !modules/**/docs/** test/** flake.lock flake.nix .github/workflows/demo.yml separator: "\n" - env: ALL_CHANGED_FILES: ${{ steps.filter.outputs.all_changed_files }} run: | echo $ALL_CHANGED_FILES build: needs: [ "path-filter" ] if: needs.path-filter.outputs.changed == 'true' strategy: fail-fast: false matrix: demo: - name: homeassistant flake: basic - name: homeassistant flake: ldap - name: nextcloud flake: basic - name: nextcloud flake: ldap - name: nextcloud flake: sso - name: minimal flake: minimal - name: minimal flake: lowlevel - name: minimal flake: sops runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} enable_kvm: true extra_nix_config: | keep-outputs = true keep-failed = true - uses: cachix/cachix-action@v17 with: name: selfhostblocks authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - name: Build ${{ matrix.demo.name }} .#${{ matrix.demo.flake }} run: | cd demo/${{ matrix.demo.name }} nix flake update --override-input selfhostblocks ../.. selfhostblocks nix \ --print-build-logs \ --option keep-going true \ --show-trace \ build .#nixosConfigurations.${{ matrix.demo.flake }}.config.system.build.toplevel nix \ --print-build-logs \ --option keep-going true \ --show-trace \ build .#nixosConfigurations.${{ matrix.demo.flake }}.config.system.build.vm result: runs-on: ubuntu-latest needs: [ "build" ] if: '!cancelled()' steps: - run: | result="${{ needs.build.result }}" if [[ $result == "success" || $result == "skipped" ]]; then exit 0 else exit 1 fi ================================================ FILE: .github/workflows/format.yaml ================================================ name: "format" on: pull_request: push: branches: [ "main" ] jobs: format: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Caching uses: cachix/cachix-action@v17 with: name: selfhostblocks authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - name: Check Formatting run: | find . -name '*.nix' | nix fmt -- --ci ================================================ FILE: .github/workflows/lock-update.yaml ================================================ name: Update Flake Lock on: workflow_dispatch: schedule: - cron: '0 0 * * *' # runs daily at 00:00 jobs: lockfile: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - name: Update flake.lock uses: DeterminateSystems/update-flake-lock@main with: token: ${{ secrets.GH_TOKEN_FOR_UPDATES }} pr-labels: | automerge ================================================ FILE: .github/workflows/pages.yml ================================================ # Inspired from https://github.com/nix-community/nix-on-droid/blob/039379abeee67144d4094d80bbdaf183fb2eabe5/.github/workflows/docs.yml name: Deploy docs on: push: branches: ["main"] workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false jobs: # Single deploy job since we're just deploying deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Nix uses: cachix/install-nix-action@v31 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Caching uses: cachix/cachix-action@v17 with: name: selfhostblocks authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - name: Build docs run: | nix \ --print-build-logs \ --option keep-going true \ --show-trace \ build .#manualHtml # see https://github.com/actions/deploy-pages/issues/58 cp \ --recursive \ --dereference \ --no-preserve=mode,ownership \ result/share/doc/selfhostblocks \ public - name: Setup Pages uses: actions/configure-pages@v6 - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: path: ./public - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 ================================================ FILE: .github/workflows/version.yaml ================================================ name: Version Bump on: push: branches: - main paths: - VERSION jobs: create-tag: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 1 - name: Get version id: vars run: echo "version=v$(cat VERSION)" >> $GITHUB_OUTPUT - uses: rickstaa/action-create-tag@v1.7.2 with: tag: ${{ steps.vars.outputs.version }} ================================================ FILE: .gitignore ================================================ *.qcow2 result result-* docs/redirects.json.backup .nixos-test-history \#*# ================================================ FILE: CHANGELOG.md ================================================ # Upcoming Release ## Breaking Changes - Bump of Nextcloud version to 32 and 33 because of nixpkgs bump. All provided apps are verified compatible with Nextcloud 33 thanks to new tests. ## New Features - Added Immich Public Proxy service - Add homepage service with dashboard contract implemented by all services - Add scrutiny service. - ZFS module now supports setting permissions - Add landing page for mailserver and dashboard contract integration ## Bug Fixes - Use configurable dataDir in arr stack - Forgejo ensures ldap is setup when sso is configured - Add nixpkgs patches on aarch64-linux too - Self-signed certs are now idempotent - Prometheus scrapes metrics at 15s interval instead of 1m ## Other Changes - Arr stack declares ldap groups, declare ApiKeys and bypasses auth for readarr when sso is enabled - Forgejo declares ldap group # v0.7.3 ## New Features - Add [mailserver module](https://shb.skarabox.com/services-mailserver.html) integrating with [Simple NixOS Mailserver](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver) and allowing full backup of an email provider. - Bump nixpkgs from https://github.com/NixOS/nixpkgs/commit/5e2a59a5b1a82f89f2c7e598302a9cacebb72a67 to https://github.com/NixOS/nixpkgs/commit/bfc1b8a4574108ceef22f02bafcf6611380c100d. [Full diff](https://github.com/nixos/nixpkgs/compare/5e2a59a5b1a82f89f2c7e598302a9cacebb72a67...bfc1b8a4574108ceef22f02bafcf6611380c100d). On top of minor changes, the most notable one was: - Updated Jellyfin LDAP and SSO plugins and configuration. @Codys-Wright ## Bug Fixes - Fix Restic and Authelia modules referencing systemd services without the `.service` suffix and leading to # v0.7.2 ## New Features - Forgejo uses secrets contract for smtp password. - Add [Firefly-iii](https://shb.skarabox.com/services-firefly-iii.html) service. - Jellyfin can [install plugins declaratively](https://shb.skarabox.com/services-jellyfin.html#services-jellyfin-options-shb.jellyfin.plugins). (Support is quite crude and WIP). - Jellyfin configures LDAP and SSO fully declaratively, including installing necessary plugins. - Nextcloud 32 is fully supported thanks to tests for version 31 and 32. ## Fixes - Revert Authelia to continue using dots in systemd service names. This caused issue with nginx name resolution. ## Other Changes - Authelia uses non deprecated `smtp.address` option. - Add documentation for Nginx block - Now a user which is only member of the admin LDAP group of a service can login. Before, some services required a user to be member of both the user and admin LDAP group. This is ensured by regression tests going forward. # v0.7.1 ## New Features - Add a Grafana dashboard showing SSL certificate renewal jobs ## Fixes - Fix let's encrypt certificate renewal jobs by removing duplicated domain name. Also adds an assertion to catch these kinds of errors. ## Other Changes - Reduce number of late SSL renewal alert by merging all metrics corresponding to one CN. # v0.7.0 ## Breaking Changes - Fix pkgs overrides not being passed to users of SelfHostBlocks. This will require to update your flake to follow the example in the [Usage](https://shb.skarabox.com/usage.html) section. ## New Features - Add a Grafana dashboard showing stats on backup jobs and also an alert if a backup job did not run in the last 24 hours or never succeeded in the last 24 hours. - Add SSO integration in Grafana. - Add Paperless service. ## Fixes - Allow to upload big files in Immich. - Only enable php-fpm Prometheus exporter if Nextcloud is enabled. ## Other Changes - Add recipe to setup DNS server with DNSSEC. # v0.6.1 ## New Features - Implement backup and databasebackup contracts with BorgBackup block. ## Fixes - Add back erroneously removed Prometheus collectors. # v0.6.0 ## Breaking Changes - Removed Nextcloud 30, update to Nextcloud 31 then after to 32. - Removed the `sops` module in the `default` NixOS module. Removed the `all` NixOS module. ## New Features - Meilisearch configured with production environment and master key. ## Other Changes - Only import hardcodedsecret module in tests. - Better usage section in manual. - Added new demo for minimal SelfHostBlocks setup, which is tested in CI. - Format all files in repo and make sure they are formatted in CI. # v0.5.1 ## New Features - Added Karakeep service with SSO integration. - Add SelfHostBlocks' `lib` into `pkgs.lib.shb`. Integrates with [Skarabox](https://github.com/ibizaman/skarabox/blob/631ff5af0b5c850bb63a3b3df451df9707c0af4e/template/flake.nix#L42-L43) too. ## Other Changes - Moved implementation guide under contributing section. # v0.5.0 ## Breaking Changes - Modules in the `nixosModules` output field do not anymore have the `system` in their path. `selfhostblocks.nixosModules.x86_64-linux.home-assistant` becomes `selfhostblocks.nixosModules.home-assistant` like it always should have been. ## Fixes - Added test case making sure a user belonging to a not authorized LDAP group cannot login. Fixed Open WebUI module. - Now importing a single module, like `selfhostblocks.nixosModules.home-assistant`, will import all needed block modules at the same time. ## Other Changes - Nextcloud module can now setup SSO integration without setting up LDAP integration. # v0.4.4 ## New Features - Added Pinchflat service with SSO integration. Declarative user creation only supported through SSO integration. - Added Immich service with SSO integration. - Added Open WebUI service with SSO integration. # v0.4.3 ## New Features - Allow user to change their SSO password in Authelia. - Make Audiobookshelf SSO integration respect admin users. ## Fixes - Fix permission on Nextcloud systemd service. - Delete Forgejo backups correctly to avoid them piling up. ## Other Changes - Add recipes section to the documentation. # v0.4.2 ## New Features - The LLDAP and Authelia modules gain a debug mode where a mitmdump instance is added so all traffic is printed. ## Fixes - By default, LLDAP module only enforces groups declaratively. Users that are not defined declaratively are not anymore deleted by inadvertence. - SSO integration with most services got fixed. A recent incompatible change in upstream Authelia broke most of them. - Fixed PostgreSQL and Home Assistant modules after nixpkgs updates. - Fixed Nextcloud module SSO integration with Authelia. - Make Nextcloud SSO integration respect admin users. # v0.4.1 ## New Features - LLDAP now manages users, groups, user attributes and group attributes declaratively. - Individual modules are exposed in the flake output for each block and service. - A mitmdump block is added that can be placed between two services and print all requests and responses. - The SSO setup for Audiobookshelf is now a bit more declarative. ## Other Changes - Forgejo got a new playwright test to check the LDAP integration. - Some renaming options have been added retroactively for jellyfin and forgejo. # v0.4.0 ## Breaking Changes - Rename ldap module to lldap as well as option name `shb.ldap` to `shb.lldap`. ## New Features - Jellyfin service now waits for Jellyfin server to be fully available before starting. - Add debug option for Jellyfin. - Allow to choose port for Jellyfin. - Make Jellyfin setup including initial admin user declarative. ## Fixes - Fix Jellyfin redirect URI scheme after update. ## Other Changes - Add documentation for LLDAP and Authelia block and link to it from other docs. # v0.3.1 ## Breaking Changes - Default version of Nextcloud is now 30. - Disable memories app on Nextcloud because it is broken. ## New Features - Add patchNixpkgs function and pre-patched patchedNixpkgs output. ## Fixes - Fix secrets passing to Nextcloud service after update. ## Other Changes - Bump nixpkgs to https://github.com/NixOS/nixpkgs/commit/216207b1e58325f3590277d9102b45273afe9878 # v0.3.0 ## New Features - Add option to add extra args to hledger command. ## Breaking Changes - Default version of Nextcloud is now 29. ## Fixes - Home Assistant config gets correctly generated with secrets even if LDAP integration is not enabled. - Fix Jellyfin SSO plugin which was left badly configured after a code refactoring. ## Other Changes - Add a lot of playwright tests for services. - Add service implementation manual page to document how to integrate a service in SHB. - Add `update-redirects` command to manage the `redirect.json` page. - Add home-assistant manual. # v0.2.10 ## New Features - Add `shb.forgejo.users` option to create users declaratively. ## Fixes - Make Nextcloud create the external storage if it's a local storage and the directory does not exist yet. - Disable flow to change password on first login for admin Forgejo user. This is not necessary since the password comes from some secret store. ## Breaking Changes - Fix internal link for Home Assistant which now points to the fqdn. This fixes Voice Assistant onboarding. This is a breaking change if one relies on reaching Home Assistant through the IP address but I don't recommend that. It's much better to have a DNS server running locally which redirects the fqdn to the server running Home Assistant. ## Other Changes - Refactor tests and add playwright tests for services. # v0.2.9 ## New Features - Add Memories Nextcloud app declaratively configured. - Add Recognize Nextcloud app declaratively configured. # v0.2.8 ## New Features - Add dashboard for SSL certificates validity and alert they did not renew on time. ## Fixes - Only enable php-fpm exporter when php-fpm is enabled. ## Breaking Changes - Remove upgrade script from postgres 13 to 14 and 14 to 15. # v0.2.7 ## New Features - Add dashboard for Nextcloud with PHP-FPM exporter. - Add voice option to Home-Assistant. ## User Facing Backwards Compatible Changes - Add hostname and domain labels for scraped Prometheus metrics and Loki logs. # v0.2.6 ## New Features - Add dashboard for deluge. # v0.2.5 ## Other Changes - Fix more modules using backup contract. # v0.2.4 ## Other Changes - Fix modules using backup contract. # v0.2.3 ## Breaking Changes - Options `before_backup` and `after_backup` for backup contract have been renamed to `beforeBackup` and `afterBackup`. - All options using the backup and databasebackup contracts now use the new style. ## Other Changes - Show how to pin Self Host Blocks flake input to a tag. # v0.2.2 ## User Facing Backwards Compatible Changes - Fix: add implementation for `sops.nix` module. ## Other Changes - Use VERSION when rendering manual too. # v0.2.1 ## User Facing Backwards Compatible Changes - Add `sops.nix` module to `nixosModules.default`. ## Other Changes - Auto-tagging of git repo when VERSION file gets updated. - Add VERSION file to track version. # v0.2.0 ## New Features - Backup: - Add feature to backup databases with the database backup contract, implemented with `shb.restic.databases`. ## Breaking Changes - Remove dependency on `sops-nix`. - Rename `shb.nginx.autheliaProtect` to `shb.nginx.vhosts`. Indeed, the option allows to define a vhost with _optional_ Authelia protection but the former name made it look like Authelia protection was enforced. - Rename all `shb.arr.*.APIKey` to `shb.arr.*.ApiKey`. - Remove `shb.vaultwarden.ldapEndpoint` option because it was not used in the implementation anyway. - Bump Nextcloud default version from 27 to 28. Add support for version 29. - Deluge config breaks the authFile into an attrset of user to password file. Also deluge has tests now. - Nextcloud now configures the LDAP app to use the `user_id` from LLDAP as the user ID used in Nextcloud. This makes all source of user - internal, LDAP and SSO - agree on the user ID. - Authelia options changed: - `shb.authelia.oidcClients.id` -> `shb.authelia.oidcClients.client_id` - `shb.authelia.oidcClients.description` -> `shb.authelia.oidcClients.client_name` - `shb.authelia.oidcClients.secret` -> `shb.authelia.oidcClients.client_secret` - `shb.authelia.ldapEndpoint` -> `shb.authelia.ldapHostname` and `shb.authelia.ldapPort` - `shb.authelia.jwtSecretFile` -> `shb.authelia.jwtSecret.result.path` - `shb.authelia.ldapAdminPasswordFile` -> `shb.authelia.ldapAdminPassword.result.path` - `shb.authelia.sessionSecretFile` -> `shb.authelia.sessionSecret.result.path` - `shb.authelia.storageEncryptionKeyFile` -> `shb.authelia.storageEncryptionKey.result.path` - `shb.authelia.identityProvidersOIDCIssuerPrivateKeyFile` -> `shb.authelia.identityProvidersOIDCIssuerPrivateKey.result.path` - `shb.authelia.smtp.passwordFile` -> `shb.authelia.smtp.password.result.path` - Make Nextcloud automatically disable maintenance mode upon service restart. - `shb.ldap.ldapUserPasswordFile` -> `shb.ldap.ldapUserPassword.result.path` - `shb.ldap.jwtSecretFile` -> `shb.ldap.jwtSecret.result.path` - Jellyfin changes: - `shb.jellyfin.ldap.passwordFile` -> `shb.jellyfin.ldap.adminPassword.result.path`. - `shb.jellyfin.sso.secretFile` -> `shb.jellyfin.ldap.sharedSecret.result.path`. - + `shb.jellyfin.ldap.sharedSecretForAuthelia`. - Forgejo changes: - `shb.forgejo.ldap.adminPasswordFile` -> `shb.forgejo.ldap.adminPassword.result.path`. - `shb.forgejo.sso.secretFile` -> `shb.forgejo.ldap.sharedSecret.result.path`. - `shb.forgejo.sso.secretFileForAuthelia` -> `shb.forgejo.ldap.sharedSecretForAuthelia.result.path`. - `shb.forgejo.adminPasswordFile` -> `shb.forgejo.adminPassword.result.path`. - `shb.forgejo.databasePasswordFile` -> `shb.forgejo.databasePassword.result.path`. - Backup: - `shb.restic.instances` options has been split between `shb.restic.instances.request` and `shb.restic.instances.settings`, matching better with contracts. - Use of secret contract everywhere. ## User Facing Backwards Compatible Changes - Add mount contract. - Export torrent metrics. - Bump chunkSize in Nextcloud to boost performance. - Fix home-assistant onboarding file generation. Added new VM test. - OIDC and SMTP config are now optional in Vaultwarden. Added new VM test. - Add default OIDC config for Authelia. This way, Authelia can start even with no config or only forward auth configs. - Fix replaceSecrets function. It wasn't working correctly with functions from `lib.generators` and `pkgs.pkgs-lib.formats`. Also more test coverage. - Add udev extra rules to allow smartctl Prometheus exporter to find NVMe drives. - Revert Loki to major version 2 because upgrading to version 3 required manual intervention as Loki refuses to start. So until this issue is tackled, reverting is the best immediate fix. See https://github.com/NixOS/nixpkgs/commit/8f95320f39d7e4e4a29ee70b8718974295a619f4 - Add prometheus deluge exporter support. It just needs the `shb.deluge.prometheusScraperPasswordFile` option to be set. ## Other Changes - Add pretty printing of test errors. Instead of: ``` error: testRadarr failed: expected {"services":{"bazarr":{},"jackett":{},"lidarr":{},"nginx":{"enable":true},"radarr":{"dataDir":"/var/lib/radarr","enable":true,"group":"radarr","user":"radarr"},"readarr":{},"sonarr":{}},"shb":{"backup":{"instances":{"radarr":{"excludePatterns":[".db-shm",".db-wal",".mono"],"sourceDirectories":["/var/lib/radarr"]}}},"nginx":{"autheliaProtect":[{"authEndpoint":"https://oidc.example.com","autheliaRules":[{"domain":"radarr.example.com","policy":"bypass","resources":["^/api.*"]},{"domain":"radarr.example.com","policy":"two_factor","subject":["group:arr_user"]}],"domain":"example.com","ssl":null,"subdomain":"radarr","upstream":"http://127.0.0.1:7878"}]}},"systemd":{"services":{"radarr":{"serviceConfig":{"StateDirectoryMode":"0750","UMask":"0027"}}},"tmpfiles":{"rules":["d '/var/lib/radarr' 0750 radarr radarr - -"]}},"users":{"groups":{"radarr":{"members":["backup"]}}}}, but got {"services":{"bazarr":{},"jackett":{},"lidarr":{},"nginx":{"enable":true},"radarr":{"dataDir":"/var/lib/radarr","enable":true,"group":"radarr","user":"radarr"},"readarr":{},"sonarr":{}},"shb":{"backup":{"instances":{"radarr":{"excludePatterns":[".db-shm",".db-wal",".mono"],"sourceDirectories":["/var/lib/radarr"]}}},"nginx":{"vhosts":[{"authEndpoint":"https://oidc.example.com","autheliaRules":[{"domain":"radarr.example.com","policy":"bypass","resources":["^/api.*"]},{"domain":"radarr.example.com","policy":"two_factor","subject":["group:arr_user"]}],"domain":"example.com","ssl":null,"subdomain":"radarr","upstream":"http://127.0.0.1:7878"}]}},"systemd":{"services":{"radarr":{"serviceConfig":{"StateDirectoryMode":"0750","UMask":"0027"}}},"tmpfiles":{"rules":["d '/var/lib/radarr' 0750 radarr radarr - -"]}},"users":{"groups":{"radarr":{"members":["backup"]}}}} ``` You now see: ``` error: testRadarr failed (- expected, + result) { "dictionary_item_added": [ "root['shb']['nginx']['vhosts']" ], "dictionary_item_removed": [ "root['shb']['nginx']['authEndpoint']" ] } ``` - Made Nextcloud LDAP setup use a hardcoded configID. This makes the detection of an existing config much more robust. # 0.1.0 Creation of CHANGELOG.md ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ ![GitHub Release](https://img.shields.io/github/v/release/ibizaman/selfhostblocks) ![GitHub commits since latest release (branch)](https://img.shields.io/github/commits-since/ibizaman/selfhostblocks/latest/main) ![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/w/ibizaman/selfhostblocks/main) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-raw/ibizaman/selfhostblocks) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-closed-raw/ibizaman/selfhostblocks?label=closed) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-raw/ibizaman/selfhostblocks) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-closed-raw/ibizaman/selfhostblocks?label=closed) [![Documentation](https://github.com/ibizaman/selfhostblocks/actions/workflows/pages.yml/badge.svg)](https://github.com/ibizaman/selfhostblocks/actions/workflows/pages.yml) [![Tests](https://github.com/ibizaman/selfhostblocks/actions/workflows/build.yaml/badge.svg)](https://github.com/ibizaman/selfhostblocks/actions/workflows/build.yaml) [![Demo](https://github.com/ibizaman/selfhostblocks/actions/workflows/demo.yml/badge.svg)](https://github.com/ibizaman/selfhostblocks/actions/workflows/demo.yml) ![Matrix](https://img.shields.io/matrix/selfhostblocks%3Amatrix.org)
# SelfHostBlocks SelfHostBlocks is: - Your escape from the cloud, for privacy and data sovereignty enthusiast. [Why?](#why-self-hosting) - A groupware to self-host [all your data](#services): documents, pictures, calendars, contacts, etc. - An opinionated NixOS server management OS for a [safe self-hosting experience](#features). - A NixOS distribution making sure all services build and work correctly thanks to NixOS VM tests. - A collection of NixOS modules standardizing options so configuring services [look the same](#unified-interfaces). - A testing ground for [contracts](#contracts) which intents to make nixpkgs modules more modular. - [Upstreaming][] as much as possible. [upstreaming]: https://github.com/pulls?page=1&q=created%3A%3E2023-06-01+is%3Apr+author%3Aibizaman+archived%3Afalse+-repo%3Aibizaman%2Fselfhostblocks+-repo%3Aibizaman%2Fskarabox ## Why Self-Hosting It is obvious by now that a deep dependency on proprietary service providers - "the cloud" - is a significant liability. One aspect often talked about is privacy which is inherently not guaranteed when using a proprietary service and is a valid concern. A more punishing issue is having your account closed or locked without prior warning When that happens, you get an instantaneous sinking feeling in your stomach at the realization you lost access to your data, possibly without recourse. Hosting services yourself is the obvious alternative to alleviate those concerns but it tends to require a lot of technical skills and time. SelfHostBlocks (together with its sibling project [Skarabox][]) aims to lower the bar to self-hosting, and provides an opinionated server management system based on NixOS modules embedding best practices. Contrary to other server management projects, its main focus is ease of long term maintenance before ease of installation. To achieve this, it provides building blocks to setup services. Some are already provided out of the box, and customizing or adding additional ones is done easily. The building blocks fit nicely together thanks to [contracts](#contracts) which SelfHostBlocks sets out to introduce into nixpkgs. This will increase modularity, code reuse and empower end users to assemble components that fit together to build their server. ## TOC - [Usage](#usage) - [At a Glance](#at-a-glance) - [Existing Installation](#existing-installation) - [Installation From Scratch](#installation-from-scratch) - [Features](#features) - [Services](#services) - [Blocks](#blocks) - [Unified Interfaces](#unified-interfaces) - [Contracts](#contracts) - [Interfacing With Other OSes](#interfacing-with-other-oses) - [Sitting on the Shoulders of a Giant](#sitting-on-the-shoulders-of-a-giant) - [Automatic Updates](#automatic-updates) - [Demos](#demos) - [Roadmap](#roadmap) - [Community](#community) - [Funding](#funding) - [License](#license) ## Usage > **Caution:** You should know that although I am using everything in this repo for my personal > production server, this is really just a one person effort for now and there are most certainly > bugs that I didn't discover yet. To get started using SelfHostBlocks, the following snippet is enough: ```nix { inputs.selfhostblocks.url = "github:ibizaman/selfhostblocks"; outputs = { selfhostblocks, ... }: let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; in nixosConfigurations = { myserver = nixpkgs'.nixosSystem { inherit system; modules = [ selfhostblocks.nixosModules.default ./configuration.nix ]; }; }; } ``` SelfHostBlocks provides its own patched nixpkgs, so you are required to use it otherwise evaluation can quickly break. [The usage section](https://shb.skarabox.com/usage.html) of the manual has more details and goes over how to deploy with [Colmena][], [nixos-rebuild][] and [deploy-rs][] and also how to handle secrets management with [SOPS][]. [Colmena]: https://shb.skarabox.com/usage.html#usage-example-colmena [nixos-rebuild]: https://shb.skarabox.com/usage.html#usage-example-nixosrebuild [deploy-rs]: https://shb.skarabox.com/usage.html#usage-example-deployrs [SOPS]: https://shb.skarabox.com/usage.html#usage-secrets Then, to actually configure services, you can choose which one interests you in the [services section](https://shb.skarabox.com/services.html) of the manual. The [recipes section](https://shb.skarabox.com/recipes.html) of the manual shows some other common use cases. Head over to the [matrix channel](https://matrix.to/#/#selfhostblocks:matrix.org) for any remaining question, or just to say hi :) ### Installation From Scratch I do recommend for this my sibling project [Skarabox][] which bootstraps a new server and sets up a few tools: - Create a bootable ISO, installable on an USB key. - Handles one or two (in raid 1) SSDs for root partition. - Handles two (in raid 1) or more hard drives for data partition. - [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) to install NixOS headlessly. - [disko](https://github.com/nix-community/disko) to format the drives using native ZFS encryption with remote unlocking through ssh. - [sops-nix](https://github.com/Mic92/sops-nix) to handle secrets. - [deploy-rs](https://github.com/serokell/deploy-rs) to deploy updates. [Skarabox]: https://github.com/ibizaman/skarabox ## Features SelfHostBlocks provides building blocks that take care of common self-hosting needs: - Backup for all services. - Automatic creation of ZFS datasets per service. - LDAP and SSO integration for most services. - Monitoring with Grafana and Prometheus stack with provided dashboards and integration with Scrutiny. - Automatic reverse proxy and certificate management for HTTPS. - VPN and proxy tunneling services. Great care is taken to make the proposed stack robust. This translates into a test suite comprised of automated NixOS VM tests which includes playwright tests to verify some important workflow like logging in. This test suite also serves as a guaranty that all services provided by SelfHostBlocks all evaluate, build and work correctly together. It works similarly as a distribution but here it's all [automated](#automatic-updates). Also, the stack fits together nicely thanks to [contracts](#contracts). ### Services [Provided services](https://shb.skarabox.com/services.html) are: - Nextcloud - Audiobookshelf - Deluge + *arr stack - Simple NixOS Mailserver - Firefly-iii - Forgejo - Grocy - Hledger - Home-Assistant - Jellyfin - Karakeep - Open WebUI - Pinchflat - Vaultwarden Like explained above, those services all benefit from out of the box backup, LDAP and SSO integration, monitoring with Grafana, reverse proxy and certificate management and VPN integration for the *arr suite. Some services do not have an entry yet in the manual. To know options for those, the only way for now is to go to the [All Options][] section of the manual. [All Options]: https://shb.skarabox.com/options.html ### Blocks The services above rely on the following [common blocks][] which altogether provides a solid foundation for self-hosting services: - Authelia - BorgBackup - Davfs - LDAP - Monitoring (Grafana - Prometheus - Loki stack + Scrutiny) - Nginx - PostgreSQL - Restic - Sops - SSL - Tinyproxy - VPN - ZFS Those blocks can be used with services not provided by SelfHostBlocks as shown [in the manual][common blocks]. [common blocks]: https://shb.skarabox.com/blocks.html The manual also provides documentation for each individual blocks. ### Unified Interfaces Thanks to the blocks, SelfHostBlocks provides an unified configuration interface for the services it provides. Compare the configuration for Nextcloud and Forgejo. The following snippets focus on similitudes and assume the relevant blocks - like secrets - are configured off-screen. It also does not show specific options for each service. These are still complete snippets that configure HTTPS, subdomain serving the service, LDAP and SSO integration. ```nix shb.nextcloud = { enable = true; subdomain = "nextcloud"; domain = "example.com"; ssl = config.shb.certs.certs.letsencrypt.${domain}; apps.ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result; }; apps.sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secret.result = config.shb.sops.secret."nextcloud/sso/secret".result; secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result; }; }; ``` ```nix shb.forgejo = { enable = true; subdomain = "forgejo"; domain = "example.com"; ssl = config.shb.certs.certs.letsencrypt.${domain}; ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result; }; sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secret.result = config.shb.sops.secret."forgejo/sso/secret".result; secretForAuthelia.result = config.shb.sops.secret."forgejo/sso/secretForAuthelia".result; }; }; ``` As you can see, they are pretty similar! This makes setting up a new service pretty easy and intuitive. SelfHostBlocks provides an ever growing list of [services](#services) that are configured in the same way. ### Contracts To make building blocks that fit nicely together, SelfHostBlocks pioneers [contracts][] which allows you, the final user, to be more in control of which piece goes where. This lets you choose, for example, any reverse proxy you want or any database you want, without requiring work from maintainers of the services you want to self host. An [RFC][] exists to upstream this concept into `nixpkgs`. The [manual][contracts] also provides an explanation of the why and how of contracts. Also, two videos exist of me presenting the topic, the first at [NixCon North America in spring of 2024][NixConNA2024] and the second at [NixCon in Berlin in fall of 2024][NixConBerlin2024]. [contracts]: https://shb.skarabox.com/contracts.html [RFC]: https://github.com/NixOS/rfcs/pull/189 [NixConNA2024]: https://www.youtube.com/watch?v=lw7PgphB9qM [NixConBerlin2024]: https://www.youtube.com/watch?v=CP0hR6w1csc ### Interfacing With Other OSes Thanks to [contracts](#contracts), one can interface NixOS with systems on other OSes. The [RFC][] explains how that works. ### Sitting on the Shoulders of a Giant By using SelfHostBlocks, you get all the benefits of NixOS which are, for self hosted applications specifically: - declarative configuration; - atomic configuration rollbacks; - real programming language to define configurations; - create your own higher level abstractions on top of SelfHostBlocks; - integration with the rest of nixpkgs; - much fewer "works on my machine" type of issues. ### Automatic Updates SelfHostBlocks follows nixpkgs unstable branch closely. There is a GitHub action running every couple of days that updates the `nixpkgs` input in the root `flakes.nix`, runs the tests and merges the PR automatically if the tests pass. A release is then made every few commits, whenever deemed sensible. On your side, to update I recommend pinning to a release with the following command, replacing the RELEASE with the one you want: ```bash RELEASE=0.2.4 nix flake update \ --override-input selfhostblocks github:ibizaman/selfhostblocks/$RELEASE \ selfhostblocks ``` ### Demos Demos that start and deploy a service on a Virtual Machine on your computer are located under the [demo](./demo/) folder. These show the onboarding experience you would get if you deployed one of the services on your own server. ## Roadmap Currently, the Nextcloud and Vaultwarden services and the SSL and backup blocks are the most advanced and most documented. Documenting all services and blocks will be done as I make all blocks and services use the contracts. Upstreaming changes is also on the roadmap. Check the [issues][] and the [milestones]() to see planned work. Feel free to add more or to contribute! [issues]: (https://github.com/ibizaman/selfhostblocks/issues) [milestones]: https://github.com/ibizaman/selfhostblocks/milestones All blocks and services have NixOS tests. Also, I am personally using all the blocks and services in this project, so they do work to some extent. ## Community This project has been the main focus of my (non work) life for the past 3 year now and I intend to continue working on this for a long time. All issues and PRs are welcome: - Use this project. Something does not make sense? Something's not working? - Documentation. Something is not clear? - New services. Have one of your preferred service not integrated yet? - Better patterns. See something weird in the code? For PRs, if they are substantial changes, please open an issue to discuss the details first. More details in [the contributing section](https://shb.skarabox.com/contributing.html) of the manual. Issues that are being worked on are labeled with the [in progress][] label. Before starting work on those, you might want to talk about it in the issue tracker or in the [matrix][] channel. The prioritized issues are those belonging to the [next milestone][milestone]. Those issues are not set in stone and I'd be very happy to solve an issue an user has before scratching my own itch. [in progress]: https://github.com/ibizaman/selfhostblocks/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22in%20progress%22 [matrix]: https://matrix.to/#/%23selfhostblocks%3Amatrix.org [milestone]: https://github.com/ibizaman/selfhostblocks/milestones One aspect that's close to my heart is I intent to make SelfHostBlocks the lightest layer on top of nixpkgs as possible. I want to upstream as much as possible. I will still take some time to experiment here but when I'm satisfied with how things look, I'll upstream changes. ## Funding I was lucky to [obtain a grant][nlnet] from NlNet which is an European fund, under [NGI Zero Core][NGI0], to work on this project. This also funds the contracts RFC. Go apply for a grant too! [nlnet]: https://nlnet.nl/project/SelfHostBlocks [NGI0]: https://nlnet.nl/core/

NlNet logo NGI Zero Core logo

## License I'm following the [Nextcloud](https://github.com/nextcloud/server) license which is AGPLv3. See [this article](https://www.fsf.org/bulletin/2021/fall/the-fundamentals-of-the-agplv3) from the FSF that explains what this license adds to the GPL one. ================================================ FILE: VERSION ================================================ 0.8.0 ================================================ FILE: demo/homeassistant/README.md ================================================ # Home Assistant Demo {#demo-homeassistant} **This whole demo is highly insecure as all the private keys are available publicly. This is only done for convenience as it is just a demo. Do not expose the VM to the internet.** The [`flake.nix`](./flake.nix) file sets up a Home Assistant server with Self Host Blocks. There are actually 2 demos: - The `basic` demo sets up a lone Home Assistant server accessible through http. - The `ldap` demo builds on top of the `basic` demo integrating Home Assistant with a LDAP provider. This guide will show how to deploy these demos to a Virtual Machine, like showed [here](https://nixos.wiki/wiki/NixOS_modules#Developing_modules). ## Deploy to the VM {#demo-homeassistant-deploy} The demos are setup to either deploy to a VM through `nixos-rebuild` or through [Colmena](https://colmena.cli.rs). Using `nixos-rebuild` is very fast and requires less steps because it reuses your nix store. Using `colmena` is more authentic because you are deploying to a stock VM, like you would with a real machine but it needs to copy over all required store derivations so it takes a few minutes the first time. ### Deploy with nixos-rebuild {#demo-homeassistant-deploy-nixosrebuild} Assuming your current working directory is the one where this Readme file is located, the one-liner command which builds and starts the VM configured to run Self Host Blocks' Nextcloud is: ```nix rm nixos.qcow2; \ nixos-rebuild build-vm --flake .#basic \ && QEMU_NET_OPTS="hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80" \ ./result/bin/run-nixos-vm ``` This will deploy the `basic` demo. If you want to deploy the `ldap` demo, use the `.#ldap` flake uris. You can even test the demos from any directory without cloning this repository by using the GitHub uri like `github:ibizaman/selfhostblocks?path=demo/nextcloud` It is very important to remove leftover `nixos.qcow2` files, if any. You can ssh into the VM like this, but this is not required for the demo: ```bash ssh -F ssh_config example ``` But before that works, you will need to change the permission of the ssh key like so: ```bash chmod 600 sshkey ``` This is only needed because git mangles with the permissions. You will not even see this change in `git status`. ### Deploy with Colmena {#demo-homeassistant-deploy-colmena} If you deploy with Colmena, you must first build the VM and start it: ```bash rm nixos.qcow2; \ nixos-rebuild build-vm-with-bootloader --fast -I nixos-config=./configuration.nix -I nixpkgs=. ; \ QEMU_NET_OPTS="hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80" ./result/bin/run-nixos-vm ``` It is very important to remove leftover `nixos.qcow2` files, if any. This last call is blocking, so I advice adding a `&` at the end of the command otherwise you will need to run the rest of the commands in another terminal. With the VM started, make the secrets in `secrets.yaml` decryptable in the VM. This change will appear in `git status` but you don't need to commit this. ```bash SOPS_AGE_KEY_FILE=keys.txt \ nix run --impure nixpkgs#sops -- --config sops.yaml -r -i \ --add-age $(nix shell nixpkgs#ssh-to-age --command sh -c 'ssh-keyscan -p 2222 -t ed25519 -4 localhost 2>/dev/null | ssh-to-age') \ secrets.yaml ``` The nested command, the one in between the parenthesis `$(...)`, is used to print the VM's public age key, which is then added to the `secrets.yaml` file in order to make the secrets decryptable by the VM. If you forget this step, the deploy will seem to go fine but the secrets won't be populated and neither LLDAP nor Home Assistant will start. Make the ssh key private: ```bash chmod 600 sshkey ``` This is only needed because git mangles with the permissions. You will not even see this change in `git status`. You can ssh into the VM with, but this is not required for the demo: ```bash ssh -F ssh_config example ``` ### Home Assistant through HTTP {#demo-homeassistant-deploy-basic} Assuming you already deployed the `basic` demo, now you must add the following entry to the `/etc/hosts` file on the host machine (not the VM): ```nix networking.hosts = { "127.0.0.1" = [ "ha.example.com" ]; }; ``` Which produces: ```bash $ cat /etc/hosts 127.0.0.1 ha.example.com ``` Go to [http://ha.example.com:8080](http://ha.example.com:8080) and you will be greeted with the Home Assistant setup wizard which will allow you to create an admin user. And that's the end of the demo ### Home Assistant with LDAP through HTTP {#demo-homeassistant-deploy-ldap} Assuming you already deployed the `ldap` demo, now you must add the following entry to the `/etc/hosts` file on the host machine (not the VM): ```nix networking.hosts = { "127.0.0.1" = [ "ha.example.com" "ldap.example.com" ]; }; ``` Which produces: ```bash $ cat /etc/hosts 127.0.0.1 ha.example.com ldap.example.com ``` Go first to [http://ldap.example.com:8080](http://ldap.example.com:8080) and login with: - username: `admin` - password: the value of the field `lldap.user_password` in the `secrets.yaml` file which is `fccb94f0f64bddfe299c81410096499a`. Create the group `homeassistant_user` and a user assigned to that group. Go to [http://ha.example.com:8080](http://ha.example.com:8080) and login with the user and password you just created above. ## In More Details {#demo-homeassistant-in-more-details} ### Files {#demo-homeassistant-files} - [`flake.nix`](./flake.nix): nix entry point, defines one target host for [colmena](https://colmena.cli.rs) to deploy to as well as the selfhostblocks' config for setting up the home assistant server paired with the LDAP server. - [`configuration.nix`](./configuration.nix): defines all configuration required for colmena to deploy to the VM. The file has comments if you're interested. - [`hardware-configuration.nix`](./hardware-configuration.nix): defines VM specific layout. This was generated with nixos-generate-config on the VM. - Secrets related files: - [`keys.txt`](./keys.txt): your private key for sops-nix, allows you to edit the `secrets.yaml` file. This file should never be published but here I did it for convenience, to be able to deploy to the VM in less steps. - [`secrets.yaml`](./secrets.yaml): encrypted file containing required secrets for Home Assistant and the LDAP server. This file can be publicly accessible. - [`sops.yaml`](./sops.yaml): describes how to create the `secrets.yaml` file. Can be publicly accessible. - SSH related files: - [`sshkey(.pub)`](./sshkey): your private and public ssh keys. Again, the private key should usually not be published as it is here but this makes it possible to deploy to the VM in less steps. - [`ssh_config`](./ssh_config): the ssh config allowing you to ssh into the VM by just using the hostname `example`. Usually you would store this info in your `~/.ssh/config` file but it's provided here to avoid making you do that. ### Virtual Machine {#demo-homeassistant-virtual-machine} _More info about the VM._ We use `build-vm-with-bootloader` instead of just `build-vm` as that's the only way to deploy to the VM. The VM's User and password are both `nixos`, as setup in the [`configuration.nix`](./configuration.nix) file under `user.users.nixos.initialPassword`. You can login with `ssh -F ssh_config example`. You just need to accept the fingerprint. The VM's hard drive is a file name `nixos.qcow2` in this directory. It is created when you first create the VM and re-used since. You can just remove it when you're done. That being said, the VM uses `tmpfs` to create the writable nix store so if you stumble in a disk space issue, you must increase the `virtualisation.vmVariantWithBootLoader.virtualisation.memorySize` setting. ### Secrets {#demo-homeassistant-secrets} _More info about the secrets can be found in the [Usage](https://shb.skarabox.com/usage.html) manual_ To open the `secrets.yaml` file and optionnally edit it, run: ```bash SOPS_AGE_KEY_FILE=keys.txt nix run --impure nixpkgs#sops -- \ --config sops.yaml \ secrets.yaml ``` The `secrets.yaml` file must follow the format: ```yaml home-assistant: country: "US" latitude: "0.100" longitude: "-0.100" time_zone: "America/Los_Angeles" lldap: user_password: XXX... jwt_secret: YYY... ``` You can generate random secrets with: ```bash $ nix run nixpkgs#openssl -- rand -hex 64 ``` If you choose a password too small, some services could refuse to start. #### Why do we need the VM's public key {#demo-homeassistant-tips-public-key-necessity} The [`sops.yaml`](./sops.yaml) file describes what private keys can decrypt and encrypt the [`secrets.yaml`](./secrets.yaml) file containing the application secrets. Usually, you will create and add secrets to that file and when deploying, it will be decrypted and the secrets will be copied in the `/run/secrets` folder on the VM. We thus need one private key for you to edit the [`secrets.yaml`](./secrets.yaml) file and one in the VM for it to decrypt the secrets. Your private key is already pre-generated in this repo, it's the [`sshkey`](./sshkey) file. But when creating the VM for Colmena, a new private key and its accompanying public key were automatically generated under `/etc/ssh/ssh_host_ed25519_key` in the VM. We just need to get the public key and add it to the `secrets.yaml` which we did in the Deploy section. ### SSH {#demo-homeassistant-tips-ssh} The private and public ssh keys were created with: ```bash ssh-keygen -t ed25519 -f sshkey ``` You don't need to copy over the ssh public key over to the VM as we set the `keyFiles` option which copies the public key when the VM gets created. This allows us also to disable ssh password authentication. For reference, if instead you didn't copy the key over on VM creating and enabled ssh authentication, here is what you would need to do to copy over the key: ```bash nix shell nixpkgs#openssh --command ssh-copy-id -i sshkey -F ssh_config example ``` ### Deploy {#demo-homeassistant-tips-deploy} If you get a NAR hash mismatch error like hereunder, you need to run `nix flake lock --update-input selfhostblocks`. ``` error: NAR hash mismatch in input ... ``` ### Update Demo {#demo-homeassistant-tips-update-demo} If you update the Self Host Blocks configuration in `flake.nix` file, you can just re-deploy. If you update the `configuration.nix` file, you will need to rebuild the VM from scratch. If you update a module in the Self Host Blocks repository, you will need to update the lock file with: ```bash nix flake lock --override-input selfhostblocks ../.. --update-input selfhostblocks ``` ================================================ FILE: demo/homeassistant/configuration.nix ================================================ { config, pkgs, ... }: let targetUser = "nixos"; targetPort = 2222; in { imports = [ # Include the results of the hardware scan. ./hardware-configuration.nix ]; boot.loader.grub.enable = true; boot.kernelModules = [ "kvm-intel" ]; system.stateVersion = "22.11"; # Options above are generate by running nixos-generate-config on the VM. # Needed otherwise deploy will say system won't be able to boot. boot.loader.grub.device = "/dev/vdb"; # Needed to avoid getting into not available disk space in /boot. boot.loader.grub.configurationLimit = 1; # The NixOS /nix/.rw-store mountpoint is backed by tmpfs which uses memory. We need to increase # the available disk space to install home-assistant. virtualisation.vmVariantWithBootLoader.virtualisation.memorySize = 8192; # Options above are needed to deploy in a VM. nix.settings.experimental-features = [ "nix-command" "flakes" ]; # We need to create the user we will deploy with. users.users.${targetUser} = { isNormalUser = true; extraGroups = [ "wheel" ]; # Enable ‘sudo’ for the user. initialPassword = "nixos"; # With this option, you don't need to use ssh-copy-id to copy the public ssh key to the VM. openssh.authorizedKeys.keyFiles = [ ./sshkey.pub ]; }; # The user we're deploying with must be able to run sudo without password. security.sudo.extraRules = [ { users = [ targetUser ]; commands = [ { command = "ALL"; options = [ "NOPASSWD" ]; } ]; } ]; # Needed to allow the user we're deploying with to write to the nix store. nix.settings.trusted-users = [ targetUser ]; # We need to enable the ssh daemon to be able to deploy. services.openssh = { enable = true; ports = [ targetPort ]; settings = { PermitRootLogin = "no"; PasswordAuthentication = false; }; }; } ================================================ FILE: demo/homeassistant/flake.nix ================================================ { description = "Home Assistant example for Self Host Blocks"; inputs = { selfhostblocks.url = "github:ibizaman/selfhostblocks"; sops-nix.url = "github:Mic92/sops-nix"; }; outputs = inputs@{ self, selfhostblocks, sops-nix, }: let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; basic = { config, ... }: { imports = [ ./configuration.nix selfhostblocks.nixosModules.authelia selfhostblocks.nixosModules.home-assistant selfhostblocks.nixosModules.sops selfhostblocks.nixosModules.ssl sops-nix.nixosModules.default ]; sops.defaultSopsFile = ./secrets.yaml; shb.home-assistant = { enable = true; domain = "example.com"; subdomain = "ha"; config = { name = "SHB Home Assistant"; country.source = config.shb.sops.secret."home-assistant/country".result.path; latitude.source = config.shb.sops.secret."home-assistant/latitude".result.path; longitude.source = config.shb.sops.secret."home-assistant/longitude".result.path; time_zone.source = config.shb.sops.secret."home-assistant/time_zone".result.path; unit_system = "metric"; }; }; shb.sops.secret."home-assistant/country".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; shb.sops.secret."home-assistant/latitude".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; shb.sops.secret."home-assistant/longitude".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; shb.sops.secret."home-assistant/time_zone".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; nixpkgs.config.permittedInsecurePackages = [ "openssl-1.1.1w" ]; }; ldap = { config, ... }: { shb.lldap = { enable = true; domain = "example.com"; subdomain = "ldap"; ldapPort = 3890; webUIListenPort = 17170; dcdomain = "dc=example,dc=com"; ldapUserPassword.result = config.shb.sops.secret."lldap/user_password".result; jwtSecret.result = config.shb.sops.secret."lldap/jwt_secret".result; }; shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPassword.request; shb.sops.secret."lldap/jwt_secret".request = config.shb.lldap.jwtSecret.request; shb.home-assistant.ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.webUIListenPort; userGroup = "homeassistant_user"; }; }; sopsConfig = { sops.age.keyFile = "/etc/sops/my_key"; environment.etc."sops/my_key".source = ./keys.txt; }; in { nixosConfigurations = { basic = nixpkgs'.nixosSystem { system = "x86_64-linux"; modules = [ basic sopsConfig ]; }; ldap = nixpkgs'.nixosSystem { system = "x86_64-linux"; modules = [ basic ldap sopsConfig ]; }; }; colmena = { meta = { nixpkgs = import nixpkgs' { system = "x86_64-linux"; }; specialArgs = inputs; }; basic = { config, ... }: { imports = [ basic ]; # Used by colmena to know which target host to deploy to. deployment = { targetHost = "example"; targetUser = "nixos"; targetPort = 2222; }; }; ldap = { config, ... }: { imports = [ basic ldap ]; # Used by colmena to know which target host to deploy to. deployment = { targetHost = "example"; targetUser = "nixos"; targetPort = 2222; }; }; }; }; } ================================================ FILE: demo/homeassistant/hardware-configuration.nix ================================================ # This file was generated by running nixos-generate-config on the VM. # # Do not modify this file! It was generated by ‘nixos-generate-config’ # and may be overwritten by future invocations. Please make changes # to /etc/nixos/configuration.nix instead. { config, lib, pkgs, modulesPath, ... }: { imports = [ (modulesPath + "/profiles/qemu-guest.nix") ]; boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "floppy" "sr_mod" "virtio_blk" ]; boot.initrd.kernelModules = [ ]; boot.kernelModules = [ "kvm-intel" ]; boot.extraModulePackages = [ ]; fileSystems."/" = { device = "/dev/vda"; fsType = "ext4"; }; fileSystems."/nix/.ro-store" = { device = "nix-store"; fsType = "9p"; }; fileSystems."/nix/.rw-store" = { device = "tmpfs"; fsType = "tmpfs"; }; fileSystems."/tmp/shared" = { device = "shared"; fsType = "9p"; }; fileSystems."/tmp/xchg" = { device = "xchg"; fsType = "9p"; }; fileSystems."/nix/store" = { device = "overlay"; fsType = "overlay"; }; fileSystems."/boot" = { device = "/dev/vdb2"; fsType = "vfat"; }; swapDevices = [ ]; # Enables DHCP on each ethernet and wireless interface. In case of scripted networking # (the default) this is the recommended approach. When using systemd-networkd it's # still possible to use this option, but it's recommended to use it in conjunction # with explicit per-interface declarations with `networking.interfaces..useDHCP`. networking.useDHCP = lib.mkDefault true; # networking.interfaces.eth0.useDHCP = lib.mkDefault true; nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; } ================================================ FILE: demo/homeassistant/keys.txt ================================================ # created: 2023-11-17T00:05:25-08:00 # public key: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 AGE-SECRET-KEY-1EPLAHXWDEM5ZZAU7NFGHT5TWU08ZUCWTHYTLD8XC89350MZ0T79SA2MQAL ================================================ FILE: demo/homeassistant/secrets.yaml ================================================ home-assistant: country: ENC[AES256_GCM,data:2Ng=,iv:/VMB6yi3e8piAx8DzLGGhLsozxWUWX2R7NcmACFng8Q=,tag:Tx0Iy1AnLmPrnYu7XtbesA==,type:str] latitude: ENC[AES256_GCM,data:p/O1HW4=,iv:CRgL4wcM3gMNu/OAHVoQuLcRD9J3SbkxsjvobiabQ0g=,tag:uIo5Rv7geOtVcarp4Qkqww==,type:str] longitude: ENC[AES256_GCM,data:sVyww6F7,iv:9EZYXSkv+rhD77lqmC+c8i+wf46KPYloVoK+ok3bWYY=,tag:c+lmtcGvULtMdu9ZTDewjA==,type:str] time_zone: ENC[AES256_GCM,data:JKXdsQZrtB1B77klxuemw1tZbg==,iv:nItJfpwp2XWmBHbohrjNMWQ8TpL2Xsv22UujZRgDscw=,tag:wrHbA1yycutUUn79F9wy6Q==,type:str] lldap: user_password: ENC[AES256_GCM,data:JrFraqFSqAhRVjB5fagIoB864aejt24q+qqWeu8ySC0=,iv:RS7VS+9tsSknn9SwpfyYVi41m3lN4SkZ4CSwrzH/Eso=,tag:5L7fx6/KhDtjHPruwac/sw==,type:str] jwt_secret: ENC[AES256_GCM,data:W1T/QoxuzMD+2AL7sP5KkMcC+GvFdd4kfd70rHLnQD+jWNs9G0igkC/BxxgbIfnSASwtSnBaaiU6/pxLFOcUVh0Nyd0Zmb/KTbagpUvSl//AZnTt/WKF9Q/8sqKzsGv0QdMyZKWi4cxiEILcTbxOsgwriFGgOJ1k5N8JEif15ig=,iv:rHlRt6nWMz8rVmU0aKH6VWWVXunOfJcDvZOxgWbK1FI=,tag:qC6N61rE8CfPSXrsEqFoIQ==,type:str] sops: kms: [] gcp_kms: [] azure_kv: [] hc_vault: [] age: - recipient: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWVU9TMjJlRzNKY0hFSktD MkFMUkg2OTZ4aFZMUUJ0UEF3OVpxWFloVWtJCmtrb2UzUDI2b0poc21Cd1A1N0xW cnBZVVNrcllVNktpS0kzRGozbHREK1UKLS0tIHZmSUhTVkRQNGUremZXQlJOOGNB SExYU3VXNVVjMElXdlVsc1VmOFRwYlEKQYeGc8F33qs3PzxXmbwqX+c+fZeEuPpv n0zBA46/HdoCYyuZsW828XVftVcQqiThq/XAe0i648k7E8Slo3Y5bg== -----END AGE ENCRYPTED FILE----- - recipient: age1slc23ln7g0ty5re2n25w3hq0sw2eyphnshe45af55vd23fgwtuhq36hpqr enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCNlpOL3lFMVA1L3NkQlE1 bnJIRlZ4Z1lCSWdJTzdtTW5SNXRXOTZ6UDJnCndwamZnWnA5TzdsSzZ4MjlTN09K YVZCZkFINDRjQWh2dFVuSmswbWw1dlkKLS0tIGdMalFlc1VrOGdHU2tIZzZoak1n VlJpS1BYd2UrZU1mZTEwU1BYODhqM2sKvQnFV8xsy1tEmYZu4izBYb7XQqTPOLTL bRkU6n17uiyXNbiXDAbX0Png/XmVG96/+Zl38BBXPQvARX8c2tzq6w== -----END AGE ENCRYPTED FILE----- lastmodified: "2024-02-12T05:07:51Z" mac: ENC[AES256_GCM,data:MOmvK0g6Wj+fND154QUhmXujsDOKMO5CRRckru+eDRPeHcJZUnI/jjolcI8y+LEdhUVf0Ln8E38GSxZT/8EW3CfCNkOUikGFdfxuQ2uzNp/1wMvNaF988lrXMBfQ7Il18AiYVK0QhGReGXJa6wBVUb2Qfrg41WC65UvQtMOByqI=,iv:Rscvq1l7YgNapC0NkabQHBzirzsPEr8ykAQqx+qGoi0=,tag:ud+K72bnUV1hnsjcewNrsw==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.8.1 ================================================ FILE: demo/homeassistant/sops.yaml ================================================ keys: - &admin age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 creation_rules: - path_regex: secrets.yaml$ key_groups: - age: - *admin ================================================ FILE: demo/homeassistant/ssh_config ================================================ Host example Port 2222 User nixos HostName 127.0.0.1 IdentityFile sshkey IdentitiesOnly yes StrictHostKeyChecking no UserKnownHostsFile=/dev/null ================================================ FILE: demo/homeassistant/sshkey ================================================ -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIwAAAJiBL8xSgS/M UgAAAAtzc2gtZWQyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIw AAAECzMZfgJIQJUVgyKZ3IYnEVvwnYXJ8nstc4/g1H41dC/vueAR1wO7hRVt7ZnMGEqfYe E9bQ+USaASlv+SQyJ4UjAAAAEWV4YW1wbGVAbG9jYWxob3N0AQIDBA== -----END OPENSSH PRIVATE KEY----- ================================================ FILE: demo/homeassistant/sshkey.pub ================================================ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPueAR1wO7hRVt7ZnMGEqfYeE9bQ+USaASlv+SQyJ4Uj example@localhost ================================================ FILE: demo/minimal/flake.nix ================================================ { description = "Minimal example to setup SelfHostBlocks"; inputs = { selfhostblocks.url = "github:ibizaman/selfhostblocks"; sops-nix = { url = "github:Mic92/sops-nix"; }; }; outputs = { self, selfhostblocks, sops-nix, }: { nixosConfigurations = let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; # This module makes the assertions happy and the build succeed. # This is of course wrong and will not work on any real system. filesystemModule = { fileSystems."/".device = "/dev/null"; boot.loader.grub.devices = [ "/dev/null" ]; }; in { # Test with: # nix build .#nixosConfigurations.minimal.config.system.build.toplevel minimal = nixpkgs'.nixosSystem { inherit system; modules = [ selfhostblocks.nixosModules.default filesystemModule # This modules showcases the use of SHB's lib. ( { config, lib, shb, ... }: { options.myOption = lib.mkOption { # Using provided nixosSystem directly. # SHB's lib is available under `shb` thanks to the overlay. type = shb.secretFileType; }; config = { myOption.source = "/a/path"; # Use the option. environment.etc.myOption.text = config.myOption.source; }; } ) ]; }; # Test with: # nix build .#nixosConfigurations.sops.config.system.build.toplevel # nix eval .#nixosConfigurations.sops.config.myOption sops = nixpkgs'.nixosSystem { inherit system; modules = [ selfhostblocks.nixosModules.default selfhostblocks.nixosModules.sops sops-nix.nixosModules.default filesystemModule # This modules showcases the use of SHB's lib. ( { config, lib, shb, ... }: { options.myOption = lib.mkOption { # Using provided nixosSystem directly. # SHB's lib is available under `shb` thanks to the overlay. type = shb.secretFileType; }; config = { myOption.source = "/a/path"; # Use the option. environment.etc.myOption.text = config.myOption.source; }; } ) ]; }; # This example shows how to import the nixosSystem patches to nixpkgs manually. # # Test with: # nix build .#nixosConfigurations.lowlevel.config.system.build.toplevel # nix eval .#nixosConfigurations.lowlevel.config.myOption lowlevel = let # We must import nixosSystem directly from the patched nixpkgs # otherwise we do not get the patches. nixosSystem' = import "${nixpkgs'}/nixos/lib/eval-config.nix"; in nixosSystem' { inherit system; modules = [ selfhostblocks.nixosModules.default filesystemModule # This modules showcases the use of SHB's lib. ( { config, lib, shb, ... }: { options.myOption = lib.mkOption { # Using provided nixosSystem directly. # SHB's lib is available under `shb` thanks to the overlay. type = shb.secretFileType; }; config = { myOption.source = "/a/path"; # Use the option. environment.etc.myOption.text = config.myOption.source; }; } ) ]; }; # This example shows how to apply patches to nixpkgs manually. # # Test with: # nix build .#nixosConfigurations.manual.config.system.build.toplevel # nix eval .#nixosConfigurations.manual.config.myOption manual = let pkgs = import selfhostblocks.inputs.nixpkgs { inherit system; }; nixpkgs' = pkgs.applyPatches { name = "nixpkgs-patched"; src = selfhostblocks.inputs.nixpkgs; patches = selfhostblocks.lib.${system}.patches; }; # We must import nixosSystem directly from the patched nixpkgs # otherwise we do not get the patches. nixosSystem' = import "${nixpkgs'}/nixos/lib/eval-config.nix"; in nixosSystem' { inherit system; modules = [ selfhostblocks.nixosModules.default filesystemModule # This modules showcases the use of SHB's lib. ( { config, lib, shb, ... }: { options.myOption = lib.mkOption { # Using provided nixosSystem directly. # SHB's lib is available under `shb` thanks to the overlay. type = shb.secretFileType; }; config = { myOption.source = "/a/path"; # Use the option. environment.etc.myOption.text = config.myOption.source; }; } ) ]; }; # Test with: # nix build .#nixosConfigurations.contractsDirect.config.system.build.toplevel contractsDirect = let nixosSystem' = import "${selfhostblocks.inputs.nixpkgs}/nixos/lib/eval-config.nix"; in nixosSystem' { inherit system; modules = [ filesystemModule (import "${selfhostblocks}/lib/module.nix") ( { config, lib, shb, ... }: { options.myOption = lib.mkOption { # Using provided nixosSystem directly. # SHB's lib is available under `shb` thanks to the overlay. type = shb.secretFileType; }; config = { myOption.source = "/a/path"; # Use the option. environment.etc.myOption.text = config.myOption.source; }; } ) ]; }; }; }; } ================================================ FILE: demo/nextcloud/README.md ================================================ # Nextcloud Demo {#demo-nextcloud} **This whole demo is highly insecure as all the private keys are available publicly. This is only done for convenience as it is just a demo. Do not expose the VM to the internet.** The [`flake.nix`](./flake.nix) file sets up a Nextcloud server with Self Host Blocks. There are actually 3 demos: - The `basic` demo sets up a lone Nextcloud server accessible through http with the Preview Generator app enabled. - The `ldap` demo builds on top of the `basic` demo integrating Nextcloud with a LDAP provider. - The `sso` demo builds on top of the `lsap` demo integrating Nextcloud with a SSO provider. They were set up by following the [manual](https://shb.skarabox.com/services-nextcloud.html). This guide will show how to deploy these demos to a Virtual Machine, like showed [here](https://nixos.wiki/wiki/NixOS_modules#Developing_modules). ## Deploy to the VM {#demo-nextcloud-deploy} The demos are setup to either deploy to a VM through `nixos-rebuild` or through [Colmena](https://colmena.cli.rs). Using `nixos-rebuild` is very fast and requires less steps because it reuses your nix store. Using `colmena` is more authentic because you are deploying to a stock VM, like you would with a real machine but it needs to copy over all required store derivations so it takes a few minutes the first time. ### Deploy with nixos-rebuild {#demo-nextcloud-deploy-nixosrebuild} Assuming your current working directory is the one where this Readme file is located, the one-liner command which builds and starts the VM configured to run Self Host Blocks' Nextcloud is: ```nix rm nixos.qcow2; \ nixos-rebuild build-vm --flake .#basic \ && QEMU_NET_OPTS="hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80" \ ./result/bin/run-nixos-vm ``` This will deploy the `basic` demo. If you want to deploy the `ldap` or `sso` demos, use respectively the `.#ldap` or `.#sso` flake uris. You can even test the demos from any directory without cloning this repository by using the GitHub uri like `github:ibizaman/selfhostblocks?path=demo/nextcloud` It is very important to remove leftover `nixos.qcow2` files, if any. You can ssh into the VM like this, but this is not required for the demo: ```bash ssh -F ssh_config example ``` But before that works, you will need to change the permission of the ssh key like so: ```bash chmod 600 sshkey ``` This is only needed because git mangles with the permissions. You will not even see this change in `git status`. ### Deploy with Colmena {#demo-nextcloud-deploy-colmena} If you deploy with Colmena, you must first build the VM and start it: ```bash rm nixos.qcow2; \ nixos-rebuild build-vm-with-bootloader --fast -I nixos-config=./configuration.nix -I nixpkgs=. ; \ QEMU_NET_OPTS="hostfwd=tcp::2222-:2222,hostfwd=tcp::8080-:80" ./result/bin/run-nixos-vm ``` It is very important to remove leftover `nixos.qcow2` files, if any. This last call is blocking, so I advice adding a `&` at the end of the command otherwise you will need to run the rest of the commands in another terminal. With the VM started, make the secrets in `secrets.yaml` decryptable in the VM. This change will appear in `git status` but you don't need to commit this. ```bash SOPS_AGE_KEY_FILE=keys.txt \ nix run --impure nixpkgs#sops -- --config sops.yaml -r -i \ --add-age $(nix shell nixpkgs#ssh-to-age --command sh -c 'ssh-keyscan -p 2222 -t ed25519 -4 localhost 2>/dev/null | ssh-to-age') \ secrets.yaml ``` The nested command, the one in between the parenthesis `$(...)`, is used to print the VM's public age key, which is then added to the `secrets.yaml` file in order to make the secrets decryptable by the VM. If you forget this step, the deploy will seem to go fine but the secrets won't be populated and Nextcloud will not start. Make the ssh key private: ```bash chmod 600 sshkey ``` This is only needed because git mangles with the permissions. You will not even see this change in `git status`. You can ssh into the VM like this, but this is not required for the demo: ```bash ssh -F ssh_config example ``` ### Nextcloud through HTTP {#demo-nextcloud-deploy-basic} :::: {.note} This section corresponds to the `basic` section of the [Nextcloud manual](services-nextcloud.html#services-nextcloudserver-usage-basic). :::: Assuming you already deployed the `basic` demo, now you must add the following entry to the `/etc/hosts` file on the host machine (not the VM): ```nix networking.hosts = { "127.0.0.1" = [ "n.example.com" ]; }; ``` Which produces: ```bash $ cat /etc/hosts 127.0.0.1 n.example.com ``` Go to [http://n.example.com:8080](http://n.example.com:8080) and login with: - username: `root` - password: the value of the field `nextcloud.adminpass` in the `secrets.yaml` file which is `43bb4b8f82fc645ce3260b5db803c5a8`. This is the admin user of Nextcloud and that's the end of the `basic` demo. ### Nextcloud with LDAP through HTTP {#demo-nextcloud-deploy-ldap} :::: {.note} This section corresponds to the `ldap` section of the [Nextcloud manual](services-nextcloud.html#services-nextcloudserver-usage-ldap). :::: Assuming you already deployed the `ldap` demo, now you must add the following entry to the `/etc/hosts` file on the host machine (not the VM): ```nix networking.hosts = { "127.0.0.1" = [ "n.example.com" "ldap.example.com" ]; }; ``` Which produces: ```bash $ cat /etc/hosts 127.0.0.1 n.example.com ldap.example.com ``` Go first to [http://ldap.example.com:8080](http://ldap.example.com:8080) and login with: - username: `admin` - password: the value of the field `lldap.user_password` in the `secrets.yaml` file which is `c2e32e54ea3e0053eb30841f818a3d9a`. Create the group `nextcloud_user` and a create a user and assign them to that group. Finally, go to [http://n.example.com/login:8080](http://n.example.com/login:8080) and login with the user and password you just created above. You might need to wait a minute or two until Nextcloud initialized correctly. Until then, you'll get a 502 Bad Gateway error. Nextcloud doesn't like being run without SSL protection, which this demo does not setup, so you might see errors loading scripts. See the `sso` demo for SSL. This is the end of the `ldap` demo. ### Nextcloud with LDAP and SSO through self-signed HTTPS {#demo-nextcloud-deploy-sso} :::: {.note} This section corresponds to the `sso` section of the [Nextcloud manual](services-nextcloud.html#services-nextcloudserver-usage-oidc). :::: At this point, it is assumed you already deployed the `sso` demo. This time, we cannot simply edit local `/etc/hosts`, because Nextcloud SSO addon must be able to connect to Authelia by domain name (`auth.example.com`). Instead, there is a `dnsmasq` server running in the VM and you must create a SOCKS proxy to connect to it like so: ```bash ssh -F ssh_config -D 1080 -N example ``` This is a blocking call but it is not necessary to fork this process in the background by appending `&` because we will not need to use the terminal for the rest of the demo. Now, configure your browser to use that SOCKS proxy. When that's done go to [https://ldap.example.com](https://ldap.example.com) and login with: - username: `admin` - password: the value of the field `lldap.user_password` in the `secrets.yaml` file which is `c2e32e54ea3e0053eb30841f818a3d9a`. Create the group `nextcloud_user` and a create a user and assign them to that group. Visit [https://auth.example.com](https://auth.example.com) and make your browserauthorize the certificate. Finally, go to [https://n.example.com](https://n.example.com) and login with the user and password you just created above. You will see that the login page is actually the one from the SSO provider. This is the end of the `sso` demo. ## In More Details {#demo-nextcloud-tips} ### Files {#demo-nextcloud-tips-files} - [`flake.nix`](./flake.nix): nix entry point, defines the target hosts for [colmena](https://colmena.cli.rs) to deploy to as well as the selfhostblocks' config for setting up Nextcloud and the auxiliary services. - [`configuration.nix`](./configuration.nix): defines all configuration required for colmena to deploy to the VM. The file has comments if you're interested. - [`hardware-configuration.nix`](./hardware-configuration.nix): defines VM specific layout. This was generated with nixos-generate-config on the VM. - Secrets related files: - [`keys.txt`](./keys.txt): your private key for sops-nix, allows you to edit the `secrets.yaml` file. This file should never be published but here I did it for convenience, to be able to deploy to the VM in less steps. - [`secrets.yaml`](./secrets.yaml): encrypted file containing required secrets for Nextcloud. This file can be publicly accessible. - [`sops.yaml`](./sops.yaml): describes how to create the `secrets.yaml` file. Can be publicly accessible. - SSH related files: - [`sshkey(.pub)`](./sshkey): your private and public ssh keys. Again, the private key should usually not be published as it is here but this makes it possible to deploy to the VM in less steps. - [`ssh_config`](./ssh_config): the ssh config allowing you to ssh into the VM by just using the hostname `example`. Usually you would store this info in your `~/.ssh/config` file but it's provided here to avoid making you do that. ### Virtual Machine {#demo-nextcloud-tips-virtual-machine} _More info about the VM._ We use `build-vm-with-bootloader` instead of just `build-vm` as that's the only way to deploy to the VM. The VM's User and password are both `nixos`, as setup in the [`configuration.nix`](./configuration.nix) file under `user.users.nixos.initialPassword`. You can login with `ssh -F ssh_config example`. You just need to accept the fingerprint. The VM's hard drive is a file name `nixos.qcow2` in this directory. It is created when you first create the VM and re-used since. You can just remove it when you're done. That being said, the VM uses `tmpfs` to create the writable nix store so if you stumble in a disk space issue, you must increase the `virtualisation.vmVariantWithBootLoader.virtualisation.memorySize` setting. ### Secrets {#demo-nextcloud-tips-secrets} _More info about the secrets can be found in the [Usage](https://shb.skarabox.com/usage.html) manual_ To open the `secrets.yaml` file and optionnally edit it, run: ```bash SOPS_AGE_KEY_FILE=keys.txt nix run --impure nixpkgs#sops -- \ --config sops.yaml \ secrets.yaml ``` You can generate random secrets with: ```bash nix run nixpkgs#openssl -- rand -hex 64 ``` If you choose secrets too small, some services could refuse to start. #### Why do we need the VM's public key {#demo-nextcloud-tips-public-key-necessity} The [`sops.yaml`](./sops.yaml) file describes what private keys can decrypt and encrypt the [`secrets.yaml`](./secrets.yaml) file containing the application secrets. Usually, you will create and add secrets to that file and when deploying, it will be decrypted and the secrets will be copied in the `/run/secrets` folder on the VM. We thus need one private key for you to edit the [`secrets.yaml`](./secrets.yaml) file and one in the VM for it to decrypt the secrets. Your private key is already pre-generated in this repo, it's the [`sshkey`](./sshkey) file. But when creating the VM for Colmena, a new private key and its accompanying public key were automatically generated under `/etc/ssh/ssh_host_ed25519_key` in the VM. We just need to get the public key and add it to the `secrets.yaml` which we did in the Deploy section. ### SSH {#demo-nextcloud-tips-ssh} The private and public ssh keys were created with: ```bash ssh-keygen -t ed25519 -f sshkey ``` You don't need to copy over the ssh public key over to the VM as we set the `keyFiles` option which copies the public key when the VM gets created. This allows us also to disable ssh password authentication. For reference, if instead you didn't copy the key over on VM creating and enabled ssh authentication, here is what you would need to do to copy over the key: ```bash $ nix shell nixpkgs#openssh --command ssh-copy-id -i sshkey -F ssh_config example ``` ### Deploy {#demo-nextcloud-tips-deploy} If you get a NAR hash mismatch error like hereunder, you need to run `nix flake lock --update-input selfhostblocks`. ``` error: NAR hash mismatch in input ... ``` ### Update Demo {#demo-nextcloud-tips-update-demo} If you update the Self Host Blocks configuration in `flake.nix` file, you can just re-deploy. If you update the `configuration.nix` file, you will need to rebuild the VM from scratch. If you update a module in the Self Host Blocks repository, you will need to update the lock file with: ```bash nix flake lock --override-input selfhostblocks ../.. --update-input selfhostblocks ``` ================================================ FILE: demo/nextcloud/configuration.nix ================================================ { config, pkgs, ... }: let targetUser = "nixos"; targetPort = 2222; in { imports = [ # Include the results of the hardware scan. ./hardware-configuration.nix ]; boot.loader.grub.enable = true; boot.kernelModules = [ "kvm-intel" ]; system.stateVersion = "22.11"; # Options above are generate by running nixos-generate-config on the VM. # Needed otherwise deploy will say system won't be able to boot. boot.loader.grub.device = "/dev/vdb"; # Needed to avoid getting into not available disk space in /boot. boot.loader.grub.configurationLimit = 1; # The NixOS /nix/.rw-store mountpoint is backed by tmpfs which uses memory. We need to increase # the available disk space to install home-assistant. virtualisation.vmVariant.virtualisation.memorySize = 8192; virtualisation.vmVariantWithBootLoader.virtualisation.memorySize = 8192; # Options above are needed to deploy in a VM. nix.settings.experimental-features = [ "nix-command" "flakes" ]; # We need to create the user we will deploy with. users.users.${targetUser} = { isNormalUser = true; extraGroups = [ "wheel" ]; # Enable ‘sudo’ for the user. initialPassword = "nixos"; # With this option, you don't need to use ssh-copy-id to copy the public ssh key to the VM. openssh.authorizedKeys.keyFiles = [ ./sshkey.pub ]; }; # The user we're deploying with must be able to run sudo without password. security.sudo.extraRules = [ { users = [ targetUser ]; commands = [ { command = "ALL"; options = [ "NOPASSWD" ]; } ]; } ]; # Needed to allow the user we're deploying with to write to the nix store. nix.settings.trusted-users = [ targetUser ]; # We need to enable the ssh daemon to be able to deploy. services.openssh = { enable = true; ports = [ targetPort ]; settings = { PermitRootLogin = "no"; PasswordAuthentication = false; }; }; } ================================================ FILE: demo/nextcloud/flake.nix ================================================ { description = "Nextcloud example for Self Host Blocks"; inputs = { selfhostblocks.url = "github:ibizaman/selfhostblocks"; sops-nix.url = "github:Mic92/sops-nix"; }; outputs = inputs@{ self, selfhostblocks, sops-nix, }: let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; basic = { config, ... }: { imports = [ ./configuration.nix selfhostblocks.nixosModules.authelia selfhostblocks.nixosModules.nextcloud-server selfhostblocks.nixosModules.nginx selfhostblocks.nixosModules.sops selfhostblocks.nixosModules.ssl sops-nix.nixosModules.default ]; sops.defaultSopsFile = ./secrets.yaml; shb.nextcloud = { enable = true; domain = "example.com"; subdomain = "n"; dataDir = "/var/lib/nextcloud"; tracing = null; defaultPhoneRegion = "US"; # This option is only needed because we do not access Nextcloud at the default port in the VM. port = 8080; adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result; apps = { previewgenerator.enable = true; }; }; shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request; # Set to true for more debug info with `journalctl -f -u nginx`. shb.nginx.accessLog = true; shb.nginx.debugLog = false; }; ldap = { config, ... }: { shb.lldap = { enable = true; domain = "example.com"; subdomain = "ldap"; ldapPort = 3890; webUIListenPort = 17170; dcdomain = "dc=example,dc=com"; ldapUserPassword.result = config.shb.sops.secret."lldap/user_password".result; jwtSecret.result = config.shb.sops.secret."lldap/jwt_secret".result; }; shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPassword.request; shb.sops.secret."lldap/jwt_secret".request = config.shb.lldap.jwtSecret.request; shb.nextcloud.apps.ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminName = "admin"; adminPassword.result = config.shb.sops.secret."nextcloud/ldap_admin_password".result; userGroup = "nextcloud_user"; }; shb.sops.secret."nextcloud/ldap_admin_password" = { request = config.shb.nextcloud.apps.ldap.adminPassword.request; settings.key = "lldap/user_password"; }; }; sso = { config, lib, ... }: { shb.certs = { cas.selfsigned.myca = { name = "My CA"; }; certs.selfsigned = { n = { ca = config.shb.certs.cas.selfsigned.myca; domain = "*.example.com"; group = "nginx"; }; }; }; shb.nextcloud = { port = lib.mkForce null; ssl = config.shb.certs.certs.selfsigned.n; }; shb.lldap.ssl = config.shb.certs.certs.selfsigned.n; services.dnsmasq = { enable = true; settings = { domain-needed = true; # no-resolv = true; bogus-priv = true; address = map (hostname: "/${hostname}/127.0.0.1") [ "example.com" "n.example.com" "ldap.example.com" "auth.example.com" ]; }; }; shb.authelia = { enable = true; domain = "example.com"; subdomain = "auth"; ssl = config.shb.certs.certs.selfsigned.n; ldapPort = config.shb.lldap.ldapPort; ldapHostname = "127.0.0.1"; dcdomain = config.shb.lldap.dcdomain; secrets = { jwtSecret.result = config.shb.sops.secret."authelia/jwt_secret".result; ldapAdminPassword.result = config.shb.sops.secret."authelia/ldap_admin_password".result; sessionSecret.result = config.shb.sops.secret."authelia/session_secret".result; storageEncryptionKey.result = config.shb.sops.secret."authelia/storage_encryption_key".result; identityProvidersOIDCHMACSecret.result = config.shb.sops.secret."authelia/hmac_secret".result; identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret."authelia/private_key".result; }; }; shb.sops.secret."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request; shb.sops.secret."authelia/ldap_admin_password" = { request = config.shb.authelia.secrets.ldapAdminPassword.request; settings.key = "lldap/user_password"; }; shb.sops.secret."authelia/session_secret".request = config.shb.authelia.secrets.sessionSecret.request; shb.sops.secret."authelia/storage_encryption_key".request = config.shb.authelia.secrets.storageEncryptionKey.request; shb.sops.secret."authelia/hmac_secret".request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request; shb.sops.secret."authelia/private_key".request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request; shb.nextcloud.apps.sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; clientID = "nextcloud"; fallbackDefaultAuth = true; secret.result = config.shb.sops.secret."nextcloud/sso/secret".result; secretForAuthelia.result = config.shb.sops.secret."authelia/nextcloud_sso_secret".result; }; shb.sops.secret."nextcloud/sso/secret".request = config.shb.nextcloud.apps.sso.secret.request; shb.sops.secret."authelia/nextcloud_sso_secret" = { request = config.shb.nextcloud.apps.sso.secretForAuthelia.request; settings.key = "nextcloud/sso/secret"; }; }; sopsConfig = { sops.age.keyFile = "/etc/sops/my_key"; environment.etc."sops/my_key".source = ./keys.txt; }; in { nixosConfigurations = { basic = nixpkgs'.nixosSystem { system = "x86_64-linux"; modules = [ sopsConfig basic ]; }; ldap = nixpkgs'.nixosSystem { system = "x86_64-linux"; modules = [ sopsConfig basic ldap ]; }; sso = nixpkgs'.nixosSystem { system = "x86_64-linux"; modules = [ sopsConfig basic ldap sso ]; }; }; colmena = { meta = { nixpkgs = import nixpkgs' { system = "x86_64-linux"; }; specialArgs = inputs; }; basic = { config, ... }: { imports = [ basic ]; deployment = { targetHost = "example"; targetUser = "nixos"; targetPort = 2222; }; }; ldap = { config, ... }: { imports = [ basic ldap ]; deployment = { targetHost = "example"; targetUser = "nixos"; targetPort = 2222; }; }; sso = { config, ... }: { imports = [ basic ldap sso ]; deployment = { targetHost = "example"; targetUser = "nixos"; targetPort = 2222; }; }; }; }; } ================================================ FILE: demo/nextcloud/hardware-configuration.nix ================================================ # This file was generated by running nixos-generate-config on the VM. # # Do not modify this file! It was generated by ‘nixos-generate-config’ # and may be overwritten by future invocations. Please make changes # to /etc/nixos/configuration.nix instead. { config, lib, pkgs, modulesPath, ... }: { imports = [ (modulesPath + "/profiles/qemu-guest.nix") ]; boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "virtio_pci" "floppy" "sr_mod" "virtio_blk" ]; boot.initrd.kernelModules = [ ]; boot.kernelModules = [ "kvm-intel" ]; boot.extraModulePackages = [ ]; fileSystems."/" = { device = "/dev/vda"; fsType = "ext4"; }; fileSystems."/nix/.ro-store" = { device = "nix-store"; fsType = "9p"; }; fileSystems."/nix/.rw-store" = { device = "tmpfs"; fsType = "tmpfs"; }; fileSystems."/tmp/shared" = { device = "shared"; fsType = "9p"; }; fileSystems."/tmp/xchg" = { device = "xchg"; fsType = "9p"; }; fileSystems."/nix/store" = { device = "overlay"; fsType = "overlay"; }; fileSystems."/boot" = { device = "/dev/vdb2"; fsType = "vfat"; }; swapDevices = [ ]; # Enables DHCP on each ethernet and wireless interface. In case of scripted networking # (the default) this is the recommended approach. When using systemd-networkd it's # still possible to use this option, but it's recommended to use it in conjunction # with explicit per-interface declarations with `networking.interfaces..useDHCP`. networking.useDHCP = lib.mkDefault true; # networking.interfaces.eth0.useDHCP = lib.mkDefault true; nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; } ================================================ FILE: demo/nextcloud/keys.txt ================================================ # created: 2023-11-17T00:05:25-08:00 # public key: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 AGE-SECRET-KEY-1EPLAHXWDEM5ZZAU7NFGHT5TWU08ZUCWTHYTLD8XC89350MZ0T79SA2MQAL ================================================ FILE: demo/nextcloud/secrets.yaml ================================================ nextcloud: adminpass: ENC[AES256_GCM,data:nD/4oml7mXbWF0axiqWQCZujFqeJMF0P/1vY9f4EPqg=,iv:KoxmL9tLPBoIJT7rxkEhxrQqZFicbEm8qXbZMrnHSGY=,tag:gwvrHsX22ygfUcOlxeC/5g==,type:str] onlyoffice: jwt_secret: ENC[AES256_GCM,data:v4BScbfRHpHAZ0MCIyb1H1vYISsR1JQRaI1mFHbZKDNhuf5Zyc6znzz+DtqXOZfVNgp9aIeWIEam0GI/O3ih0jzEN0ut/jqI3onoSghq22h2VTKdLMcT6JG2p/R1mHgD+C7KeeepcdWMbwLXswi2jBys3FyxTY3mfiNv3AcndGA=,iv:TFs+fTlMGWKTVJ3pUmXCpGskQ2h6uSLr+TlmG6OXQYg=,tag:Ixm0VtO5ySCQxiKweDop0A==,type:str] sso: secret: ENC[AES256_GCM,data:9uZfvBXETbP47Cf6lZNLqskqmbxcAaQ/e3jiHqW9VweqrmByyadaE3DgCcODUJNEatuFxIyP+ptBdeX9FBRPmAvVl/BaK5oKzp84i+5zb1nvxvxBx+KQhqFKZgk81jJQeMSxwLlDKguWnLx83QhYvOMphZNQOeLQ/Cx+qrvCWsk=,iv:pF87avRdm2tgwA+cQnvcYSUIxAh18jDrMA6eAHoyBZU=,tag:FaJwUr2fR9dZUdDOfq/C5Q==,type:str] lldap: user_password: ENC[AES256_GCM,data:4ImmaC2T1hj6L8tzrxv4d7/I4F9xEA/uuc56QOqkY08=,iv:SljGhXi3SYoMNcR9onwqthOAyFX1D8KsegmWRypbblQ=,tag:Aw+juIV2AM0J+89itNDjVA==,type:str] jwt_secret: ENC[AES256_GCM,data:btABIOGRgioXmPe8QirhyozQzhVaAcF2sbB07hevz+Q=,iv:vBOq4Mab3RE69rOA8ZbMX72Gm3KEng6HaCveZrXsIrU=,tag:zkbJ+SeNnzQyAZxOjso8fg==,type:str] authelia: jwt_secret: ENC[AES256_GCM,data:xom/W92DGS2RafO+olwG8oKAbKPbkPKyZ2mYv0lWqtVAWUFwSoCGLgxe4uHAoGcLosJmDxU/srq+HNPzYORY8+mHn9wMoQgYg2oceLw2xamYdkIzvswof6LoYAV7MaZReYgYXcqMy2LZuU3PnnE4wag3liSuEx4qtJrLKB52ljE=,iv:t5PsBdZDze3/4S8utfnkmiToaorqq5BiJn99JuRirXY=,tag:ZJCszIOpaSwl9Sua8VWHoA==,type:str] storage_encryption_key: ENC[AES256_GCM,data:wUmF+0etuhEr3FNy7x0LBJunn1vmWO+IExm/wgkh0CEDWzxblpylC/PGAGgHdlJMQOhUY6tDPD67sJgO2g+yTBB3lfOo/kql0gnGVKQjRMMHqfEEmXK56yXP+J2JePJ6DlaqzdAXko4Tmh4GnRKsswMQZVA5PDOuHHNRcVTCb0E=,iv:wz1Mry7jMwGvD9mF1/PbQsHb/jmm8WOWchLL95YADeY=,tag:AZp43iti+nxW0TYK7MlYNg==,type:str] session_secret: ENC[AES256_GCM,data:TSe2YEyXl0Ls8wAynUYRJBQL8mbC1i/31ueuCj7d7ouO9gCX/Igz6OM9EgWigxucsMVQkiUtDCI9DD9B8jFaYGMIiB9FrKQnixigptrIUj210zJ3Aer38GyFxSI541PaBzmnauEo1MtBykjSg93xyI6ivB8FJmmauQOMYNiTYvk=,iv:OBtUCw7BevaF3VQKLJ2HiB828IzJqS27SZUOoAqoD+E=,tag:WfCGlHi6a15AYeSFXnnOVw==,type:str] hmac_secret: ENC[AES256_GCM,data:RmPr/kJmimMmeZCluMBsYL+w5VtJ1IZNFo2VOVNGiu0ajMJoK06RQx9AAYb+GvPRrGz9wzRy38hTH7unIiq59WOZCw245StsawSCeszadh8RrjPJPNCKPt3vaBbIzlvz0xMvgX4UT2k+uK1dqR7QXiCrBDludU3nnHIpbgkcADM=,iv:z5KLaAlevgk2HsxMWggU1DL0g+Ae+DaBLZ0SnZoKYcA=,tag:2ChIOxMCI4psqIhX+GE8EQ==,type:str] private_key: ENC[AES256_GCM,data:ogK8+ecyRhd1OrhpmjtXUK2Lyhg/D9FJwTwC2HtlmViLrAx5ovKGcZOrHQ2JFhCvRTj2n54+Me2JpOS70v0ugLTOVbZtw1eYJr7rNohhu8nBITuDYOkEVHZ7Q6xZ5Toabm2/y6yr2zJuj9N8NPCPv5uf6h3/DzBITvUkF9l79ypAgs5yP8JZYvpkG2aMv6bkRp5+9H9nNywrDdggwdEF0kRl+KS0gd4SpDLJzJ00W+hpPRd0qYukCJaKCKs/76vklN47Xk9UVnwYZP4dxh0unYJdffuBYSJXrAEP/bxOdyszZ2v0vr7HYOY7/+o/S1ZOmVqlv+1EVWpluDytuwnfpNdxY7I2HJjvM7Ua5zs8KmGKz/er0wFjWeakP7l+YKs+vi8IEG6sJJiakuI8n4neZmQrHf9MG5ynYKhA55+gaaiJYg29CTHxWpYsDyEQhoDxHeyxGhe7zQ8SeEVyADtfkHYB6hGJrds0wAJaZ9cqyKpmvqsogh0ipUBmt62ytAl0KMEZ7IgRHIq6P+q+sFXvgUyehh85+Ud13F+gQvMkITN2r6c+BS1/u8gMv9jHi/3mvLtA3nBB86w06QRFrG6fHkn1UBGjjQ5TvPlVmJBTM8pDVb3VFlsX2xRHS7lCX4AAy4JXEXJFWUSWN1zZ/3caE3ishgMnJbptGw5JS5/DQsu0s+CtUKjv8Xup0eiZ/12+0AzRTjihRAysCprnuqUvlb6OP4vDP5PNbDCAZLF/LYfGhIiOGTph8hC5Hmxql3mDmW0CN6VfdtpBgPfPp1SMEYTh07gBxxNqGVViAjOTF31NWuektF9vKtmFEeX1CqY512KtvW5Q9zIs/MBrSeONPZYrr01xEEFeqet8T/kqaiTGpI3fFHwiDmfKajlcFj9xPlhzvMbQV3eFBdC0FdxssTrWP3cEs03vexA+bLEM4E0aSagTpd0TWFeZd4AjG8GsjF80c7P/CSScg3yYYHFI3V6ljRY72oKh9361UnmdU3Y1PGcjmw2nVkVlywRAK+P6F4tRqhmKrYzNxw+AHGEgCG347pYPeRKj12acyX8lmuFa7ULCFoHng+DJITR26HrHDhzzg7/EDIc8c7orfvWS5qYlPIsfwSMBrwfm91eGgcgnEhrLE4krpgSZcrQTXOhC4cinzxqK+GhoERfFzRMNzxjVaEFCO0yQuLnydwJWdBMCV4gEUNmCwWGDNaBYqKLPdyK753LEylb/MAOmqX/YBp7rhLNt/5P6kSacZzIvqLYt9zbITrrF13nDLmP3ZOhIbGRVG1F3r2ReIW+PnmHxHnSrOV4tMel0ql2nlcLIh41WHiKCaeIiQ0zPQOBXJbXis2pCuJ0Mut68abe0bicFpEDrQ5CgIjTWOzbxFq+tVvmX2amTGRW51048mPuod0ZofYPzI9l6YwO7eO02mEYCUhcgPFZaIBnEF9cZHWtBGturXi/A6LNh/6bnFnozy4SdaUrmlK2o4uaBPr/av4zjSFOQaZ6+NQOysmdLUzupq6ysbsOZIS7cUngr5v7otxxvR/qYtC4SNOkSaU53LsfSuxvu3/5nOMAr+O9ez/N+BJV5GHbABJ6l2TkeWjbDL/RcCE0nXHkwLH1YEGBmt93KTagjCOt/n+GN0/F/7jQtxhNjI84CRHzYAdzqSXP78J1vcqctPWhQBrWfNioQkjLwy5+ndDT1Uu7yVTMPD3teQq7sjQ4g6hfHqmjWWSewxnopxkJyz/ZnwQqTXig1QXwTVcIUI/IiNp9a4D/kI5eEUbbjbXD7nScaoIb5b7oE9eK6/qmRmKv8m4ySKiFN5kNwME/y2a17K2w5HwCOi8cqPKT7LHegLoYQvJ9DrIFLHQZpKjYfKQAxTcDyFitJvSxbcD2iM2eZMXuNKcbyHOHwZr2sC4BicF+0InfsOZE8R1+v4/98elbisnV1rSO38/1vxQZzV8x5JHbZpMegD/cdxxC3fsgwsoyBt3wynrt1S8xqqIxuwbEX8uN2J8qR1YYK7Qn1mDhlyl8dTvI7W65HLpF5HWC1Hn+nDB/arjL9jEeMWVimxkMIpGt69337uyugfJsu54uiIm70zpzsh+otYDvvZUrvG/KoDejP40ZbmtsLknbpmzr1ezgrxYV9W4NJcjZtZ4x64pXKvSrbYt+yMorXujmVFg0og/TxnkJ3kJdB6a3sg4bymPnJiAAl9ya3DYHHPQrHXQa2LnV02ps6Y6jq0LdKy3cu7DxOYtmpI6KRz0k1fRVBNBnSX7aUEsZ8aZ9XYTB1FuvT,iv:WYnE50BRK5Q7TA/24LDDkSq+wu9+S6ckb3+NR/eHkUs=,tag:X9s50TdOPEjDV+7Kv6prOQ==,type:str] sops: kms: [] gcp_kms: [] azure_kv: [] hc_vault: [] age: - recipient: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqRjg2SWR0SjhpWExqbi9E a3pJbXJyMmMyY1F5NFNVNWY0TXRicFdycEhJCkdWL1dmNjdCRVhKNmllcGpmNkNV U1lTUjI3elBoOStNZVhoL1o3WGZLWjgKLS0tIE1XRTVPUE91d2k2dFpMbVJ1a0ZB dTNrOUhzOSsvRnNSMC9VOTJaY1orWUEK8IcLk/4X7O+ZRosM7KNQNSEgyGkFklRw YSutsre5OOEUx1X+hxzu2GF9I4DGcSAbQtzPYBq7qcwxUR+oIXiJyQ== -----END AGE ENCRYPTED FILE----- lastmodified: "2025-03-17T00:29:32Z" mac: ENC[AES256_GCM,data:eE3F1K/brgKMnixJQo/A/VYjafNLAGKuSq1n8857yjsiNnro/hwDy9jNKLH3a6/5DX/aOjMfZJzgH3ycb7f4771IohrWoDLjymaVdgJXsTITXZaLQyN+QHoOTRbXAJwG1f4Mr2kEAdwK7JLtu9TqX82o2DmBWNRxkkn1Kv5NjiA=,iv:OSAI0b4H40xbzKQbD6F2B5Xu/8enUIclfds8uYH/q3o=,tag:fYTnxx8IQYMyXAeVTUiQ+A==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.9.2 ================================================ FILE: demo/nextcloud/sops.yaml ================================================ keys: - &admin age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 creation_rules: - path_regex: secrets.yaml$ key_groups: - age: - *admin ================================================ FILE: demo/nextcloud/ssh_config ================================================ Host example Port 2222 User nixos HostName 127.0.0.1 IdentityFile sshkey IdentitiesOnly yes StrictHostKeyChecking no UserKnownHostsFile=/dev/null ================================================ FILE: demo/nextcloud/sshkey ================================================ -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIwAAAJiBL8xSgS/M UgAAAAtzc2gtZWQyNTUxOQAAACD7ngEdcDu4UVbe2ZzBhKn2HhPW0PlEmgEpb/kkMieFIw AAAECzMZfgJIQJUVgyKZ3IYnEVvwnYXJ8nstc4/g1H41dC/vueAR1wO7hRVt7ZnMGEqfYe E9bQ+USaASlv+SQyJ4UjAAAAEWV4YW1wbGVAbG9jYWxob3N0AQIDBA== -----END OPENSSH PRIVATE KEY----- ================================================ FILE: demo/nextcloud/sshkey.pub ================================================ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPueAR1wO7hRVt7ZnMGEqfYeE9bQ+USaASlv+SQyJ4Uj example@localhost ================================================ FILE: docs/blocks.md ================================================ # Blocks {#blocks} Blocks help you self-host apps or services. They implement a specific function like backup or secure access through a subdomain. Each block is designed to be usable on its own and to fit nicely with others. All blocks are implemented under the blocks folder [in the repository](@REPO@/modules/blocks). All services in SHB document how to setup the various blocks provided here. For custom services or those not provided by SHB, the [Expose a service Recipe](recipes-exposeService.html) explains how to use the blocks here. ## Authentication {#blocks-category-authentication} ```{=include=} chapters html:into-file=//blocks-authelia.html modules/blocks/authelia/docs/default.md ``` ```{=include=} chapters html:into-file=//blocks-lldap.html modules/blocks/lldap/docs/default.md ``` ## Backup {#blocks-category-backup} ```{=include=} chapters html:into-file=//blocks-borgbackup.html modules/blocks/borgbackup/docs/default.md ``` ```{=include=} chapters html:into-file=//blocks-restic.html modules/blocks/restic/docs/default.md ``` ## Database {#blocks-category-database} ```{=include=} chapters html:into-file=//blocks-postgresql.html modules/blocks/postgresql/docs/default.md ``` ## Secrets {#blocks-category-secrets} ```{=include=} chapters html:into-file=//blocks-sops.html modules/blocks/sops/docs/default.md ``` ## Network {#blocks-category-network} ```{=include=} chapters html:into-file=//blocks-ssl.html modules/blocks/ssl/docs/default.md ``` ```{=include=} chapters html:into-file=//blocks-nginx.html modules/blocks/nginx/docs/default.md ``` ## Introspection {#blocks-category-introspection} ```{=include=} chapters html:into-file=//blocks-monitoring.html modules/blocks/monitoring/docs/default.md ``` ```{=include=} chapters html:into-file=//blocks-mitmdump.html modules/blocks/mitmdump/docs/default.md ``` ================================================ FILE: docs/contracts.md ================================================ # Contracts {#contracts} ::: {.note} An [RFC][] has been created which is the most up-to-date version of contracts. The text here is still relevant although the implementation itself has changed a little bit. [RFC]: https://github.com/NixOS/rfcs/pull/189 ::: A contract decouples modules that use a functionality from modules that provide it. A first intuition for contracts is they are generally related to accessing a shared resource. A few examples of contracts are generating SSL certificates, creating a user or knowing which files and folders to backup. Indeed, when generating certificates, the service using those do not care how they were created. They just need to know where the certificate files are located. A contract is made between a `requester` module and a `provider` module. For example, a `backup` contract can be made between the [Nextcloud service][] and the [Restic service][]. The former is the `requester` - the one wanted to be backed up - and the latter is the `provider` of the contract - the one backing up files. The `backup contract` would then say which set of options the `requester` and `provider` modules must use to talk to each other. [Nextcloud service]: ./services-nextcloud.html [Restic service]: ./blocks-restic.html ## Provided contracts {#contracts-provided} Self Host Blocks is a proving ground of contracts. This repository adds a layer on top of services available in nixpkgs to make them work using contracts. In time, we hope to upstream as much of this as possible, reducing the quite thick layer that it is now. Provided contracts are: - [SSL generator contract](contracts-ssl.html) to generate SSL certificates. Two providers are implemented: self-signed and Let's Encrypt. - [Backup contract][] to backup directories. Two providers are implemented: [BorgBackup][] and [Restic][]. - [Database Backup contract](contracts-databasebackup.html) to backup database dumps. One provider is implemented: [BorgBackup][] and [Restic][]. - [Contract for Secrets](contracts-secret.html) to provide secrets that are deployed outside of the Nix store. One provider is implemented: [SOPS][]. - [Dashboard contract](contracts-dashboard.html) to show services in a nice user-facing dashboard. One provider is implemented: [Homepage][]. [backup contract]: contracts-backup.html [borgbackup]: blocks-borgbackup.html [homepage]: services-homepage.html [restic]: blocks-restic.html [sops]: blocks-sops.html ```{=include=} chapters html:into-file=//contracts-ssl.html modules/contracts/ssl/docs/default.md ``` ```{=include=} chapters html:into-file=//contracts-backup.html modules/contracts/backup/docs/default.md ``` ```{=include=} chapters html:into-file=//contracts-databasebackup.html modules/contracts/databasebackup/docs/default.md ``` ```{=include=} chapters html:into-file=//contracts-secret.html modules/contracts/secret/docs/default.md ``` ```{=include=} chapters html:into-file=//contracts-dashboard.html modules/contracts/dashboard/docs/default.md ``` ## Problem Statement {#contracts-why} Currently in nixpkgs, every module accessing a shared resource must either implement the logic needed to setup that resource themselves or either instruct the user how to set it up themselves. For example, this is what the Nextcloud module looks like. It sets up the `nginx module` and a database, letting you choose between multiple databases. ![](./assets/contracts_before.png "A module composed of a core logic and a lot of peripheral logic.") This has a few disadvantages: _I'm using the Nextcloud module to make the following examples more concrete but this applies to all other modules._ - This leads to a lot of **duplicated code**. If the Nextcloud module wants to support a new type of database, the maintainer of the Nextcloud module must do the work. And if another module wants to support it too, the maintainers of that module cannot re-use easily the work of the Nextcloud maintainer, apart from copy-pasting and adapting the code. - This also leads to **tight coupling**. The code written to integrate Nextcloud with the Nginx reverse proxy is hard to decouple and make generic. Letting the user choose between Nginx and another reverse proxy will require a lot of work. - There is also a **lack of separation of concerns**. The maintainers of a service must be experts in all implementations they let the users choose from. - This is **not extendable**. If you, the user of the module, want to use another implementation that is not supported, you are out of luck. You can always dive into the module's code and extend it with a lot of `mkForce`, but that is not an optimal experience. - Finally, there is **no interoperability**. It is not currently possible to integrate the Nextcloud module with an existing database or reverse proxy or other type of shared resource that already exists on a non-NixOS machine. We do believe that the decoupling contracts provides helps alleviate all the issues outlined above which makes it an essential step towards better interoperability. ![](./assets/contracts_after.png "A module containing only logic using peripheral logic through contracts.") Indeed, contracts allow: - **Reuse of code**. Since the implementation of a contract lives outside of modules using it, using the same implementation and code elsewhere without copy-pasting is trivial. - **Loose coupling**. Modules that use a contract do not care how they are implemented as long as the implementation follows the behavior outlined by the contract. - Full **separation of concerns** (see diagram below). Now, each party's concern is separated with a clear boundary. The maintainer of a module using a contract can be different from the maintainers of the implementation, allowing them to be experts in their own respective fields. But more importantly, the contracts themselves can be created and maintained by the community. - Full **extensibility**. The final user themselves can choose an implementation, even new custom implementations not available in nixpkgs, without changing existing code. - **Incremental adoption**. Contracts can help bridge a NixOS system with any non-NixOS one. For that, one can hardcode a requester or provider module to match how the non-NixOS system is configured. The responsibility falls of course on the user to make sure both system agree on the configuration. - Last but not least, **Testability**. Thanks to NixOS VM test, we can even go one step further by ensuring each implementation of a contract, even custom ones, provides required options and behaves as the contract requires thanks to generic NixOS tests. For an example, see the [generic backup contract test][generic backup test] and the [instantiated NixOS tests][instantiated backup test] ensuring the providers do implement the contract correctly. ![](./assets/contracts_separationofconcerns.png "Separation of concerns thanks to contracts.") ## Concept {#contracts-concept} Conceptually, a contract is an attrset of options with a defined behavior. Let's take a reduced `secret` contract as example. This contract allows a `requester` module to ask for a secret and a `provider` module to generate that secret outside of the nix store and provide it back to the `requester`. In this case, the options for the contract could look like so: _The full secret contract can be found [here][secret contract]._ [secret contract]: ./contracts-secret.html ```nix { lib, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule str; in { # Filled out by the requester module. request = mkOption { type = submodule { options = { owner = mkOption { description = "Linux user owning the secret file."; type = str; }; }; }; }; # Filled out by the provider module. result = mkOption { type = submodule { options = { path = mkOption { description = "Linux user owning the secret file."; type = str; }; }; }; }; # Options specific for each provider. settings = mkOption { type = submodule { options = { encryptedFile = mkOption { description = "Encrypted file containing the secret."; type = path; }; }; }; }; } ``` Unfortunately, the contract needs to be more complicated to handle several constraints. 1. First, to fill out the contract, the `requester` must set the defaults for the `request.*` options and the `provider` for the `result.*` options. Since one cannot do that after calling the `mkOption` function, the `request` and `result` attributes must be functions taking in the defaults as arguments. 2. Another constraint is a `provider` module of a contract will need to work for several `requester` modules. This means that the option to provide the contract will be an `attrsOf` of something, not just plainly the contract. Think of a provider for the secret contract, if it didn't use `attrsOf`, one could only create an unique secret for all the modules, which is not useful. 3. Also, one usually want the defaults for the contract to be computed from some other option. For a `provider` module, the options in the `result` could be computed from the `name` provided in the `attrsOf` or from a value given in the `request` or `setting` attrset. For example, a `provider` module for the `secret` contract would want something like the following in pseudo code: ```nix services.provier = { secret = mkOption { type = attrsOf (submodule ({ name, ... }: { result = { path = mkOption { type = str; default = "/run/secrets/${name}"; }; }; })) }; }; ``` Another example is for a `provider` module for the `backup` contract which would want the name of the restore script to depend on the path to the repository it is backing up to. This is necessary to differentiate which source to restore from in case one wants to backup a same `requester` service to multiple different repositories. One could be local and another remote, for example. ```nix services.provider = { backup = mkOption { type = attrsOf (submodule ({ name, config, ... }: { settings = { }; result = { restoreScript = { type = str; default = "provider-restore-${name}-${config.settings.repository.path}"; }; }; })); }; }; ``` 4. Finally, the last constraint, which is also the more demanding, is we want to generate the documentation for the options with `nixos-generate-config`. For that, the complicated `default` we give to options that depend on other options break the documentation generation. So instead of using only `default`, we must also define `defaultText` attributes. This means the actual `mkRequest` and `mkResult` functions must take twice as many arguments as there are option. One for the `default` and the other for the `defaultText`. This will not be shown in the following snippets as it is already complicated enough. These are all the justifications to why the final contract structure is as presented in the next section. It makes it harder to write, but much easier to use, which is nice property. ## Schema {#contracts-schema} A contract for a version of the [backup contract][] with less options would look like so: ```nix { lib, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule str; in { mkRequest = { owner ? "root", }: mkOption { default = { inherit owner; }; type = submodule { options = { owner = mkOption { description = "Linux user owning the secret file."; type = str; default = owner; }; }; }; }; mkResult = { path ? "/run/secrets/secret", }: mkOption { type = submodule { options = { path = mkOption { description = "Linux user owning the secret file."; type = str; default = path; }; }; }; }; } ``` Assuming the `services.requester` module needs to receive a password from the user and wants to use the `secret contract` for that, it would then setup the option like so: ```nix { pkgs, lib, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule; contracts = pkgs.callPackage ./modules/contracts {}; mkRequester = requestCfg: { request = contracts.secret.mkRequest requestCfg; result = contracts.secret.mkResult {}; }; in { options.services.requester = { password = mkOption { description = "Password for the service."; type = submodule { options = mkRequester { owner = "requester"; }; }; }; }; config = { // Use config.services.requester.password.result.path }; } ``` A provider that can create multiple secrets would have an `attrsOf` option and use the contract in it like so: ```nix let inherit (lib) mkOption; inherit (lib.types) attrsOf submodule; contracts = pkgs.callPackage ./modules/contracts {}; mkProvider = module: { resultCfg, settings ? {}, }: { request = contracts.secret.mkRequest {}; result = contracts.secret.mkResult resultCfg; } // optionalAttrs (settings != {}) { inherit settings; }; in { options.services.provider = { secrets = mkOption { type = attrsOf (submodule ({ name, options, ... }: { options = mkProvider { resultCfg = { path = "/run/secrets/${name}"; }; settings = mkOption { description = "Settings specific to the Sops provider."; type = attrsOf (submodule { options = { repository = mkOption { }; }; }); default = {}; }; }; })); }; }; } ``` The `mkRequester` and `mkProvider` are provided by Self Host Blocks as they are generic, so the actual syntax is a little bit different. They were copied here that way so the snippets were self-contained. To see a full contract in action, the secret contract is a good example. It is composed of: - [the contract][secret contract ref], - [the mkRequester and mkProvider][contract lib] functions, - [a requester][], - [a provider][]. [secret contract ref]: ./contracts-secret.html#contract-secret-options [contract lib]: @REPO@/modules/contracts/default.nix [a requester]: ./blocks-sops.html#blocks-sops-options-shb.sops.secret [a provider]: ./services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass ## Contract Tests {#contracts-test} To make sure all providers module of a contract have the same behavior, generic NixOS VM tests exist per contract. They are generic because they work on any module, as long as the module implements the contract of course. A simplified test for a secret contract would look like the following. First, there is the generic test: ```nix { pkgs, lib, shb, ... }: let inherit (lib) getAttrFromPath setAttrByPath; in { name, configRoot, settingsCfg, modules ? [], owner ? "root", content ? "secretPasswordA", }: shb.test.runNixOSTest { inherit name; nodes.machine = { config, ... }: { imports = modules; config = setAttrByPath configRoot { secretA = { request = { inherit owner; }; settings = settingsCfg content; }; }; }; testScript = { nodes, ... }: let result = (getAttrFromPath configRoot nodes.machine)."A".result; in '' owner = machine.succeed("stat -c '%U' ${result.path}").strip() if owner != "${owner}": raise Exception(f"Owner should be '${owner}' but got '{owner}'") content = machine.succeed("cat ${result.path}").strip() if content != "${content}": raise Exception(f"Content should be '${content}' but got '{content}'") ''; } ``` This test is generic because it sets the `request` on an option whose path is not yet known. It achieves this by calling `setAttrByPath configRoot` where `configRoot` is a path to a module, for example `[ "services" "provider" ]` for a module whose root option is under `services.provider`. This test validates multiple aspects of the contract: - The provider must understand the options of the `request`. Here `request.owner`. - The provider correctly provides the expected result. Here the location of the secret in the `result.path` option. - The provider must behave as expected. Here, the secret located at `result.path` must have the correct `owner` and the correct `content`. Instantiating the test for a given provider looks like so: ```nix { hardcoded_root = contracts.test.secret { name = "hardcoded_root"; modules = [ ./modules/blocks/hardcodedsecret.nix ]; configRoot = [ "shb" "hardcodedsecret" ]; settingsCfg = secret: { content = secret; }; }; hardcoded_user = contracts.test.secret { name = "hardcoded_user"; owner = "user"; modules = [ ./modules/blocks/hardcodedsecret.nix ]; configRoot = [ "shb" "hardcodedsecret" ]; settingsCfg = secret: { content = secret; }; }; } ``` Validating a new provider is then just a matter of extending the above snippet. To see a full contract test in action, the test for backup contract is a good example. It is composed of: - the [generic test][generic backup test] - and [instantiated tests][instantiated backup test] for some providers. [generic backup test]: @REPO@/modules/contracts/backup/test.nix [instantiated backup test]: @REPO@/test/contracts/backup.nix ## Videos {#contracts-videos} Two videos exist of me presenting the topic, the first at [NixCon North America in spring of 2024][NixConNA2024] and the second at [NixCon in Berlin in fall of 2024][NixConBerlin2024]. [NixConNA2024]: https://www.youtube.com/watch?v=lw7PgphB9qM [NixConBerlin2024]: https://www.youtube.com/watch?v=CP0hR6w1csc ## Are there contracts in nixpkgs already? {#contracts-nixpkgs} Actually not quite, but close. There are some ubiquitous options in nixpkgs. Those I found are: - `services..enable` - `services..package` - `services..openFirewall` - `services..user` - `services..group` What makes those nearly contracts are: - Pretty much every service provides them. - Users of a service expects them to exist and expects a consistent type and behavior from them. Indeed, everyone knows what happens if you set `enable = true`. - Maintainers of a service knows that users expects those options. They also know what behavior the user expects when setting those options. - The name of the options is the same everywhere. The only thing missing to make these explicit contracts is, well, the contracts themselves. Currently, they are conventions and not contracts. ================================================ FILE: docs/contributing.md ================================================ # Contributing {#contributing} All issues and Pull Requests are welcome! - Use this project. Something does not make sense? Something's not working? - Documentation. Something is not clear? - New services. Have one of your preferred service not integrated yet? - Better patterns. See something weird in the code? For PRs, if they are substantial changes, please open an issue to discuss the details first. More details in [the contributing section](https://shb.skarabox.com/contributing.html) of the manual. Issues that are being worked on are labeled with the [in progress][] label. Before starting work on those, you might want to talk about it in the issue tracker or in the [matrix][] channel. The prioritized issues are those belonging to the [next milestone][milestone]. Those issues are not set in stone and I'd be very happy to solve an issue an user has before scratching my own itch. [in progress]: https://github.com/ibizaman/selfhostblocks/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22in%20progress%22 [matrix]: https://matrix.to/#/%23selfhostblocks%3Amatrix.org [milestone]: https://github.com/ibizaman/selfhostblocks/milestones first. ## Chat Support {#contributing-chat} Come hang out in the [Matrix channel](https://matrix.to/#/%23selfhostblocks%3Amatrix.org). :) ## Upstream Changes {#contributing-upstream} One important goal of SHB is to be the smallest amount of code above what is available in [nixpkgs](https://github.com/NixOS/nixpkgs). It should be the minimum necessary to make packages available there conform with the contracts. This way, there are less chance of breakage when nixpkgs gets updated. I intend to upstream to nixpkgs as much of those as makes sense. ## Run tests {#contributing-runtests} Run all tests: ```bash $ nix flake check # or $ nix run github:Mic92/nix-fast-build -- --skip-cached --flake ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)" ``` Run one group of tests: ```bash $ nix build .#checks.${system}.modules $ nix build .#checks.${system}.vm_postgresql_peerAuth ``` ### Playwright Tests {#contributing-playwright-tests} If the test includes playwright tests, you can see the playwright trace with: ```bash $ nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip ``` ### Debug Tests {#contributing-debug-tests} Run the test in driver interactive mode: ```bash $ nix run .#checks.${system}.vm_postgresql_peerAuth.driverInteractive ``` When you get to the shell, start the server and/or client with one of the following commands: ```bash server.start() client.start() start_all() ``` To run the test from the shell, use `test_script()`. Note that if the test script ends in error, the shell will exit and you will need to restart the VMs. After the shell started, you will see lines like so: ``` SSH backdoor enabled, the machines can be accessed like this: Note: this requires systemd-ssh-proxy(1) to be enabled (default on NixOS 25.05 and newer). client: ssh -o User=root vsock/3 server: ssh -o User=root vsock/4 ``` With the following command, you can directly access the server's nginx instance with your browser at `http://localhost:8000`: ```bash ssh-keygen -R vsock/4; ssh -o User=root -L 8000:localhost:80 vsock/4 ``` ## Upload test results to CI {#contributing-upload} Github actions do now have hardware acceleration, so running them there is not slow anymore. If needed, the tests results can still be pushed to cachix so they can be reused in CI. After running the `nix-fast-build` command from the previous section, run: ```bash $ find . -type l -name "result-vm_*" | xargs readlink | nix run nixpkgs#cachix -- push selfhostblocks ``` ## Upload package to CI {#contributing-upload-package} In the rare case where a package must be built but cannot in CI, for example because of not enough memory, you can push the package directly to the cache with: ```bash nix build .#checks.x86_64-linux.vm_karakeep_backup.nodes.server.services.karakeep.package readlink result | nix run nixpkgs#cachix -- push selfhostblocks ``` ## Deploy using colmena {#contributing-deploy-colmena} ```bash $ nix run nixpkgs#colmena -- apply ``` ## Use a local version of selfhostblocks {#contributing-localversion} This works with any flake input you have. Either, change the `.url` field directly in you `flake.nix`: ```nix selfhostblocks.url = "/home/me/projects/selfhostblocks"; ``` Or override on the command line: ```bash $ nix flake lock --override-input selfhostblocks ../selfhostblocks ``` I usually combine the override snippet above with deploying: ```bash $ nix flake lock --override-input selfhostblocks ../selfhostblocks && nix run nixpkgs#colmena -- apply ``` ## Diff changes {#contributing-diff} First, you must know what to compare. You need to know the path to the nix store of what is already deployed and to what you will deploy. ### What is deployed {#contributing-diff-deployed} To know what is deployed, either just stash the changes you made and run `build`: ```bash $ nix run nixpkgs#colmena -- build ... Built "/nix/store/yyw9rgn8v5jrn4657vwpg01ydq0hazgx-nixos-system-baryum-23.11pre-git" ``` Or ask the target machine: ```bash $ nix run nixpkgs#colmena -- exec -v readlink -f /run/current-system baryum | /nix/store/77n1hwhgmr9z0x3gs8z2g6cfx8gkr4nm-nixos-system-baryum-23.11pre-git ``` ### What will get deployed {#contributing-diff-todeploy} Assuming you made some changes, then instead of deploying with `apply`, just `build`: ```bash $ nix run nixpkgs#colmena -- build ... Built "/nix/store/16n1klx5cxkjpqhrdf0k12npx3vn5042-nixos-system-baryum-23.11pre-git" ``` ### Get the full diff {#contributing-diff-full} With `nix-diff`: ``` $ nix run nixpkgs#nix-diff -- \ /nix/store/yyw9rgn8v5jrn4657vwpg01ydq0hazgx-nixos-system-baryum-23.11pre-git \ /nix/store/16n1klx5cxkjpqhrdf0k12npx3vn5042-nixos-system-baryum-23.11pre-git \ --color always | less ``` ### Get version bumps {#contributing-diff-version} A nice summary of version changes can be produced with: ```bash $ nix run nixpkgs#nvd -- diff \ /nix/store/yyw9rgn8v5jrn4657vwpg01ydq0hazgx-nixos-system-baryum-23.11pre-git \ /nix/store/16n1klx5cxkjpqhrdf0k12npx3vn5042-nixos-system-baryum-23.11pre-git \ ``` ## Generate random secret {#contributing-gensecret} ```bash $ nix run nixpkgs#openssl -- rand -hex 64 ``` ## Write code {#contributing-code} ```{=include=} chapters html:into-file=//service-implementation-guide.html service-implementation-guide.md ``` ## Links that helped {#contributing-links} While creating NixOS tests: - https://www.haskellforall.com/2020/11/how-to-use-nixos-for-lightweight.html - https://nixos.org/manual/nixos/stable/index.html#sec-nixos-tests While creating an XML config generator for Radarr: - https://stackoverflow.com/questions/4906977/how-can-i-access-environment-variables-in-python - https://stackoverflow.com/questions/7771011/how-can-i-parse-read-and-use-json-in-python - https://github.com/NixOS/nixpkgs/blob/master/pkgs/build-support/writers/scripts.nix - https://stackoverflow.com/questions/43837691/how-to-package-a-single-python-script-with-nix - https://ryantm.github.io/nixpkgs/languages-frameworks/python/#python - https://ryantm.github.io/nixpkgs/hooks/python/#setup-hook-python - https://ryantm.github.io/nixpkgs/builders/trivial-builders/ - https://discourse.nixos.org/t/basic-flake-run-existing-python-bash-script/19886 - https://docs.python.org/3/tutorial/inputoutput.html - https://pypi.org/project/json2xml/ - https://www.geeksforgeeks.org/serialize-python-dictionary-to-xml/ - https://nixos.org/manual/nix/stable/language/builtins.html#builtins-toXML - https://github.com/NixOS/nixpkgs/blob/master/pkgs/pkgs-lib/formats.nix ================================================ FILE: docs/default.nix ================================================ # Taken nearly verbatim from https://github.com/nix-community/home-manager/pull/4673 # Read these docs online at https://shb.skarabox.com. { pkgs, buildPackages, lib, nmdsrc, stdenv, documentation-highlighter, nixos-render-docs, release, allModules, version ? builtins.readFile ../VERSION, substituteVersionIn, modules, }: let shbPath = toString ./..; gitHubDeclaration = user: repo: subpath: let urlRef = "main"; end = if subpath == "" then "" else "/" + subpath; in { url = "https://github.com/${user}/${repo}/blob/${urlRef}${end}"; name = "<${repo}${end}>"; }; ghRoot = (gitHubDeclaration "ibizaman" "selfhostblocks" "").url; buildOptionsDocs = { modules, filterOptionPath ? null, }: args: let config = { _module.check = false; _module.args = { }; system.stateVersion = "22.11"; }; utils = import "${pkgs.path}/nixos/lib/utils.nix" { inherit config lib; pkgs = null; }; eval = lib.evalModules { inherit modules; specialArgs = { inherit utils; }; }; options = lib.setAttrByPath filterOptionPath (lib.getAttrFromPath filterOptionPath eval.options); in buildPackages.nixosOptionsDoc ( { inherit options; transformOptions = opt: opt // { # Clean up declaration sites to not refer to the Home Manager # source tree. declarations = map ( decl: gitHubDeclaration "ibizaman" "selfhostblocks" ( lib.removePrefix "/" (lib.removePrefix shbPath (toString decl)) ) ) opt.declarations; }; } // builtins.removeAttrs args [ "includeModuleSystemOptions" ] ); scrubbedModule = { _module.args.pkgs = lib.mkForce (nmd.scrubDerivations "pkgs" pkgs); _module.check = false; }; allOptionsDocs = paths: (buildOptionsDocs { modules = paths ++ allModules ++ [ scrubbedModule ]; filterOptionPath = [ "shb" ]; } { variablelistId = "selfhostblocks-options"; } ).optionsJSON; individualModuleOptionsDocs = filterOptionPath: paths: (buildOptionsDocs { modules = paths ++ [ scrubbedModule ]; inherit filterOptionPath; } { variablelistId = "selfhostblocks-options"; } ).optionsJSON; nmd = import nmdsrc { inherit lib; # The DocBook output of `nixos-render-docs` doesn't have the change # `nmd` uses to work around the broken stylesheets in # `docbook-xsl-ns`, so we restore the patched version here. pkgs = pkgs // { docbook-xsl-ns = pkgs.docbook-xsl-ns.override { withManOptDedupPatch = true; }; }; }; outputPath = "share/doc/selfhostblocks"; manpage-urls = pkgs.writeText "manpage-urls.json" "{}"; in stdenv.mkDerivation { name = "self-host-blocks-manual"; nativeBuildInputs = [ nixos-render-docs ]; # We include the parent so we get the documentation inside the root # modules/ and demo/ folders. src = ./..; buildPhase = '' cd docs mkdir -p demo cp -t . -r ../demo cp -t . -r ../modules mkdir -p out/media mkdir -p out/highlightjs mkdir -p out/static cp -t out/highlightjs \ ${documentation-highlighter}/highlight.pack.js \ ${documentation-highlighter}/LICENSE \ ${documentation-highlighter}/mono-blue.css \ ${documentation-highlighter}/loader.js cp -t out/static \ ${nmdsrc}/static/style.css \ ${nmdsrc}/static/highlightjs/tomorrow-night.min.css \ ${nmdsrc}/static/highlightjs/highlight.min.js \ ${nmdsrc}/static/highlightjs/highlight.load.js '' + lib.concatStringsSep "\n" ( map (m: '' substituteInPlace ${m} --replace '@VERSION@' ${version} '') substituteVersionIn ) + '' substituteInPlace ./options.md \ --replace \ '@OPTIONS_JSON@' \ ${ allOptionsDocs [ (pkgs.path + "/nixos/modules/services/misc/forgejo.nix") ] }/share/doc/nixos/options.json '' + lib.concatStringsSep "\n" ( lib.mapAttrsToList ( name: cfg': let cfg = if builtins.isAttrs cfg' then cfg' else { module = cfg'; }; module = if builtins.isList cfg.module then cfg.module else [ cfg.module ]; optionRoot = cfg.optionRoot or [ "shb" (lib.last (lib.splitString "/" name)) ]; in '' substituteInPlace ./modules/${name}/docs/default.md \ --replace-fail \ '@OPTIONS_JSON@' \ ${individualModuleOptionsDocs optionRoot module}/share/doc/nixos/options.json '' ) modules ) + '' find . -name "*.md" -print0 | \ while IFS= read -r -d ''' f; do substituteInPlace "''${f}" \ --replace-quiet \ '@REPO@' \ "${ghRoot}" 2>/dev/null done nixos-render-docs manual html \ --manpage-urls ${manpage-urls} \ --redirects ./redirects.json \ --media-dir media \ --revision ${lib.trivial.revisionWithDefault release} \ --stylesheet static/style.css \ --stylesheet static/tomorrow-night.min.css \ --script static/highlight.min.js \ --script static/highlight.load.js \ --toc-depth 1 \ --section-toc-depth 1 \ manual.md \ out/index.html ''; installPhase = '' dest="$out/${outputPath}" mkdir -p "$(dirname "$dest")" mv out "$dest" mkdir -p $out/nix-support/ echo "doc manual $dest index.html" >> $out/nix-support/hydra-build-products ''; } ================================================ FILE: docs/demos.md ================================================ # Demos {#demos} These demos are showcasing what Self Host Blocks can do. They deploy a block or a service on a VM on your local machine with minimal manual steps. ```{=include=} chapters html:into-file=//demo-homeassistant.html demo/homeassistant/README.md ``` ```{=include=} chapters html:into-file=//demo-nextcloud.html demo/nextcloud/README.md ``` ================================================ FILE: docs/generate-redirects-nixos-render-docs.py ================================================ #!/usr/bin/env python3 """ Generate redirects.json by scanning actual HTML files produced by nixos-render-docs. This script implements a runtime patching mechanism to automatically generate a complete redirects.json file by scanning generated HTML files for real anchor locations, eliminating manual maintenance and ensuring accuracy. ARCHITECTURE OVERVIEW: The script works by monkey-patching nixos-render-docs at runtime to: 1. Disable redirect validation during HTML generation 2. Generate HTML documentation normally 3. Scan all generated HTML files to extract anchor IDs and their file locations 4. Apply filtering logic to exclude system-generated anchors 5. Generate and write redirects.json with accurate mappings KEY COMPONENTS: - Runtime patching: Modifies nixos-render-docs behavior without source changes - HTML scanning: Extracts anchor IDs using regex pattern matching - Filtering: Excludes NixOS options (opt-*) and extra options (selfhostblock*) - Output generation: Creates both debug information and production redirects.json IMPORTANT NOTES: - Uses atexit handler to ensure output is generated even if process is interrupted - Patches are applied on module import, making this a side-effect import - Error handling preserves original validation behavior in case of failure """ import sys import json import atexit import os import re # Global storage for anchor-to-file mappings discovered during HTML scanning # Structure: {anchor_id: html_filename} file_target_mapping = {} def scan_html_files(output_dir, html_files): """ Scan HTML files to extract anchor IDs and build anchor-to-file mappings. Discovers all HTML files in output_dir and extracts id attributes to populate the global file_target_mapping. Filters out NixOS system options during scanning. Args: output_dir: Directory containing generated HTML files html_files: Unused parameter (always discovers files from filesystem) """ # Always discover HTML files from the output directory if not os.path.exists(output_dir): print(f"DEBUG: Output directory {output_dir} does not exist", file=sys.stderr) return html_files = [f for f in os.listdir(output_dir) if f.endswith('.html')] print(f"DEBUG: Discovered {len(html_files)} HTML files in {output_dir}", file=sys.stderr) # Process each HTML file to extract anchor IDs for html_file in html_files: html_path = os.path.join(output_dir, html_file) try: with open(html_path, 'r', encoding='utf-8') as f: html_content = f.read() # Extract all id attributes using regex pattern matching # Matches: id="anchor-name" and captures anchor-name anchor_matches = re.findall(r'id="([^"]+)"', html_content) # Filter and record anchor mappings non_opt_count = 0 for anchor_id in anchor_matches: # Skip NixOS system option anchors (opt-* prefix) if not anchor_id.startswith('opt-'): file_target_mapping[anchor_id] = html_file non_opt_count += 1 if non_opt_count > 0: print(f"Found {non_opt_count} anchors in {html_file}", file=sys.stderr) except Exception as e: # Log errors but continue processing other files print(f"Failed to scan {html_path}: {e}", file=sys.stderr) def output_collected_refs(): """ Generate and write the final redirects.json file from collected anchor mappings. This function is registered as an atexit handler to ensure output is generated even if the process is interrupted. It processes the global file_target_mapping to create the final redirects file with appropriate filtering. Output files: - out/redirects.json: Production redirects mapping """ import os # Generate redirects from discovered HTML anchor mappings if file_target_mapping: print(f"Creating redirects from {len(file_target_mapping)} HTML mappings", file=sys.stderr) redirects = {} filtered_count = 0 # Apply filtering logic to exclude system-generated anchors for anchor_id, html_file in file_target_mapping.items(): # Filter out: # - opt-*: NixOS system options # - selfhostblock*: Extra options from this project if not anchor_id.startswith('opt-') and not anchor_id.startswith('selfhostblock'): redirects[anchor_id] = [f"{html_file}#{anchor_id}"] else: filtered_count += 1 print(f"Generated {len(redirects)} redirects (filtered out {filtered_count} system options)", file=sys.stderr) else: # Fallback case - should not occur during normal operation print("Warning: No HTML mappings available", file=sys.stderr) redirects = {} # Ensure output directory exists os.makedirs('out', exist_ok=True) # Write production redirects file try: redirects_file = 'out/redirects.json' with open(redirects_file, 'w') as f: json.dump(redirects, f, indent=2, sort_keys=True) print(f"Generated redirects.json with {len(redirects)} redirects", file=sys.stderr) except Exception as e: print(f"Failed to write redirects.json: {e}", file=sys.stderr) # Register output generation to run on process exit atexit.register(output_collected_refs) def apply_patches(): """ Apply runtime monkey patches to nixos-render-docs modules. This function modifies the behavior of nixos-render-docs by: 1. Hooking into the HTML generation CLI command 2. Temporarily disabling redirect validation during HTML generation 3. Scanning generated HTML files to extract anchor mappings 4. Restoring original validation behavior The patching approach allows us to extract anchor information without modifying the nixos-render-docs source code directly. Raises: ImportError: If nixos-render-docs modules cannot be imported """ try: # Import required nixos-render-docs modules import nixos_render_docs.html as html_module import nixos_render_docs.redirects as redirects_module import nixos_render_docs.manual as manual_module # Store reference to original HTML CLI function original_run_cli_html = manual_module._run_cli_html def patched_run_cli_html(args): """ Patched version of _run_cli_html that disables validation and scans output. This wrapper function: 1. Temporarily disables redirect validation to prevent errors 2. Runs normal HTML generation 3. Scans generated HTML files for anchor mappings 4. Restores original validation behavior """ print("Generating HTML documentation...", file=sys.stderr) # Temporarily disable redirect validation original_validate = redirects_module.Redirects.validate redirects_module.Redirects.validate = lambda self, targets: None try: # Run original HTML generation result = original_run_cli_html(args) # Determine output directory from CLI arguments if hasattr(args, 'outfile') and args.outfile: output_dir = os.path.dirname(args.outfile) else: output_dir = '.' # Scan generated HTML files for anchor mappings scan_html_files(output_dir, None) print(f"Scanned {len(file_target_mapping)} anchor mappings", file=sys.stderr) finally: # Always restore original validation function redirects_module.Redirects.validate = original_validate return result # Replace the original function with our patched version manual_module._run_cli_html = patched_run_cli_html print("Applied patches to nixos-render-docs", file=sys.stderr) except ImportError as e: print(f"Failed to apply patches: {e}", file=sys.stderr) # Apply patches immediately when this module is imported # This ensures the patches are active before nixos-render-docs CLI runs apply_patches() ================================================ FILE: docs/manual.md ================================================ # Self Host Blocks Manual {#self-host-blocks-manual} ## Version @VERSION@ ```{=include=} preface preface.md ``` ```{=include=} chapters html:into-file=//usage.html usage.md ``` ```{=include=} chapters html:into-file=//services.html services.md ``` ```{=include=} chapters html:into-file=//contracts.html contracts.md ``` ```{=include=} chapters html:into-file=//blocks.html blocks.md ``` ```{=include=} chapters html:into-file=//recipes.html recipes.md ``` ```{=include=} chapters html:into-file=//demos.html demos.md ``` ```{=include=} chapters html:into-file=//contributing.html contributing.md ``` ```{=include=} appendix html:into-file=//options.html options.md ``` ================================================ FILE: docs/options.md ================================================ # All Options {#all-options} ```{=include=} options id-prefix: opt- list-id: selfhostblocks-options source: @OPTIONS_JSON@ ``` ================================================ FILE: docs/preface.md ================================================ # Preface {#preface} ::: {.note} Self Host Blocks is hosted on [GitHub](https://github.com/ibizaman/selfhostblocks). If you encounter problems or bugs then please report them on the [issue tracker](https://github.com/ibizaman/selfhostblocks/issues). Feel free to join the dedicated Matrix room [matrix.org#selfhostblocks](https://matrix.to/#/#selfhostblocks:matrix.org). ::: SelfHostBlocks is: - Your escape from the cloud, for privacy and data sovereignty enthusiast. [Why?](#preface-why-self-hosting) - A groupware to self-host [all your data](#preface-services): documents, pictures, calendars, contacts, etc. - An opinionated NixOS server management OS for a [safe self-hosting experience](#preface-features). - A NixOS distribution making sure all services build and work correctly thanks to NixOS VM tests. - A collection of NixOS modules standardizing options so configuring services [look the same](#preface-unified-interfaces). - A testing ground for [contracts](#preface-contracts) which intents to make nixpkgs modules more modular. - [Upstreaming][] as much as possible. [upstreaming]: https://github.com/pulls?page=1&q=created%3A%3E2023-06-01+is%3Apr+author%3Aibizaman+archived%3Afalse+-repo%3Aibizaman%2Fselfhostblocks+-repo%3Aibizaman%2Fskarabox ## Why Self-Hosting {#preface-why-self-hosting} It is obvious by now that a deep dependency on proprietary service providers - "the cloud" - is a significant liability. One aspect often talked about is privacy which is inherently not guaranteed when using a proprietary service and is a valid concern. A more punishing issue is having your account closed or locked without prior warning When that happens, you get an instantaneous sinking feeling in your stomach at the realization you lost access to your data, possibly without recourse. Hosting services yourself is the obvious alternative to alleviate those concerns but it tends to require a lot of technical skills and time. SelfHostBlocks (together with its sibling project [Skarabox][]) aims to lower the bar to self-hosting, and provides an opinionated server management system based on NixOS modules embedding best practices. Contrary to other server management projects, its main focus is ease of long term maintenance before ease of installation. To achieve this, it provides building blocks to setup services. Some are already provided out of the box, and customizing or adding additional ones is done easily. The building blocks fit nicely together thanks to [contracts](#contracts) which SelfHostBlocks sets out to introduce into nixpkgs. This will increase modularity, code reuse and empower end users to assemble components that fit together to build their server. ## Usage {#preface-usage} > **Caution:** You should know that although I am using everything in this repo for my personal > production server, this is really just a one person effort for now and there are most certainly > bugs that I didn't discover yet. To get started using SelfHostBlocks, the following snippet is enough: ```nix { inputs.selfhostblocks.url = "github:ibizaman/selfhostblocks"; outputs = { selfhostblocks, ... }: let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; in nixosConfigurations = { myserver = nixpkgs'.nixosSystem { inherit system; modules = [ selfhostblocks.nixosModules.default ./configuration.nix ]; }; }; } ``` SelfHostBlocks provides its own patched nixpkgs, so you are required to use it otherwise evaluation can quickly break. [The usage section](https://shb.skarabox.com/usage.html) of the manual has more details and goes over how to deploy with [Colmena][], [nixos-rebuild][] and [deploy-rs][] and also how to handle secrets management with [SOPS][]. [Colmena]: https://shb.skarabox.com/usage.html#usage-example-colmena [nixos-rebuild]: https://shb.skarabox.com/usage.html#usage-example-nixosrebuild [deploy-rs]: https://shb.skarabox.com/usage.html#usage-example-deployrs [SOPS]: https://shb.skarabox.com/usage.html#usage-secrets Then, to actually configure services, you can choose which one interests you in the [services section](https://shb.skarabox.com/services.html) of the manual. The [recipes section](https://shb.skarabox.com/recipes.html) of the manual shows some other common use cases. Head over to the [matrix channel](https://matrix.to/#/#selfhostblocks:matrix.org) for any remaining question, or just to say hi :) ### Installation From Scratch {#preface-usage-installation-from-scratch} I do recommend for this my sibling project [Skarabox][] which bootstraps a new server and sets up a few tools: - Create a bootable ISO, installable on an USB key. - Handles one or two (in raid 1) SSDs for root partition. - Handles two (in raid 1) or more hard drives for data partition. - [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) to install NixOS headlessly. - [disko](https://github.com/nix-community/disko) to format the drives using native ZFS encryption with remote unlocking through ssh. - [sops-nix](https://github.com/Mic92/sops-nix) to handle secrets. - [deploy-rs](https://github.com/serokell/deploy-rs) to deploy updates. [Skarabox]: https://github.com/ibizaman/skarabox ## Features {#preface-features} SelfHostBlocks provides building blocks that take care of common self-hosting needs: - Backup for all services. - Automatic creation of ZFS datasets per service. - LDAP and SSO integration for most services. - Monitoring with Grafana and Prometheus stack with provided dashboards. - Automatic reverse proxy and certificate management for HTTPS. - VPN and proxy tunneling services. Great care is taken to make the proposed stack robust. This translates into a test suite comprised of automated NixOS VM tests which includes playwright tests to verify some important workflow like logging in. This test suite also serves as a guaranty that all services provided by SelfHostBlocks all evaluate, build and work correctly together. It works similarly as a distribution but here it's all [automated](#preface-updates). Also, the stack fits together nicely thanks to [contracts](#preface-contracts). ### Services {#preface-services} [Provided services](https://shb.skarabox.com/services.html) are: - Nextcloud - Audiobookshelf - Deluge + *arr stack - Forgejo - Grocy - Hledger - Home-Assistant - Jellyfin - Karakeep - Open WebUI - Pinchflat - Vaultwarden Like explained above, those services all benefit from out of the box backup, LDAP and SSO integration, monitoring with Grafana, reverse proxy and certificate management and VPN integration for the *arr suite. Some services do not have an entry yet in the manual. To know options for those, the only way for now is to go to the [All Options][] section of the manual. [All Options]: https://shb.skarabox.com/options.html ### Blocks {#preface-blocks} The services above rely on the following [common blocks][] which altogether provides a solid foundation for self-hosting services: - Authelia - BorgBackup - Davfs - LDAP - Monitoring (Grafana - Prometheus - Loki stack) - Nginx - PostgreSQL - Restic - Sops - SSL - Tinyproxy - VPN - ZFS Those blocks can be used with services not provided by SelfHostBlocks as shown [in the manual][common blocks]. [common blocks]: https://shb.skarabox.com/blocks.html The manual also provides documentation for each individual blocks. ### Unified Interfaces {#preface-unified-interfaces} Thanks to the blocks, SelfHostBlocks provides an unified configuration interface for the services it provides. Compare the configuration for Nextcloud and Forgejo. The following snippets focus on similitudes and assume the relevant blocks - like secrets - are configured off-screen. It also does not show specific options for each service. These are still complete snippets that configure HTTPS, subdomain serving the service, LDAP and SSO integration. ```nix shb.nextcloud = { enable = true; subdomain = "nextcloud"; domain = "example.com"; ssl = config.shb.certs.certs.letsencrypt.${domain}; apps.ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result; }; apps.sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secret.result = config.shb.sops.secret."nextcloud/sso/secret".result; secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result; }; }; ``` ```nix shb.forgejo = { enable = true; subdomain = "forgejo"; domain = "example.com"; ssl = config.shb.certs.certs.letsencrypt.${domain}; ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/admin_password".result; }; sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secret.result = config.shb.sops.secret."forgejo/sso/secret".result; secretForAuthelia.result = config.shb.sops.secret."forgejo/sso/secretForAuthelia".result; }; }; ``` As you can see, they are pretty similar! This makes setting up a new service pretty easy and intuitive. SelfHostBlocks provides an ever growing list of [services](#preface-services) that are configured in the same way. ### Contracts {#preface-contracts} To make building blocks that fit nicely together, SelfHostBlocks pioneers [contracts][] which allows you, the final user, to be more in control of which piece goes where. This lets you choose, for example, any reverse proxy you want or any database you want, without requiring work from maintainers of the services you want to self host. An [RFC][] exists to upstream this concept into `nixpkgs`. The [manual][contracts] also provides an explanation of the why and how of contracts. Also, two videos exist of me presenting the topic, the first at [NixCon North America in spring of 2024][NixConNA2024] and the second at [NixCon in Berlin in fall of 2024][NixConBerlin2024]. [contracts]: https://shb.skarabox.com/contracts.html [RFC]: https://github.com/NixOS/rfcs/pull/189 [NixConNA2024]: https://www.youtube.com/watch?v=lw7PgphB9qM [NixConBerlin2024]: https://www.youtube.com/watch?v=CP0hR6w1csc ### Interfacing With Other OSes {#preface-interface} Thanks to [contracts](#contracts), one can interface NixOS with systems on other OSes. The [RFC][] explains how that works. ### Sitting on the Shoulders of a Giant {#preface-giants} By using SelfHostBlocks, you get all the benefits of NixOS which are, for self hosted applications specifically: - declarative configuration; - atomic configuration rollbacks; - real programming language to define configurations; - create your own higher level abstractions on top of SelfHostBlocks; - integration with the rest of nixpkgs; - much fewer "works on my machine" type of issues. ### Automatic Updates {#preface-updates} SelfHostBlocks follows nixpkgs unstable branch closely. There is a GitHub action running every couple of days that updates the `nixpkgs` input in the root `flakes.nix`, runs the tests and merges the PR automatically if the tests pass. A release is then made every few commits, whenever deemed sensible. On your side, to update I recommend pinning to a release with the following command, replacing the RELEASE with the one you want: ```bash RELEASE=0.2.4 nix flake update \ --override-input selfhostblocks github:ibizaman/selfhostblocks/$RELEASE \ selfhostblocks ``` ### Demos {#preface-demos} Demos that start and deploy a service on a Virtual Machine on your computer are located under the [demo](./demo/) folder. These show the onboarding experience you would get if you deployed one of the services on your own server. ## Roadmap {#preface-roadmap} Currently, the Nextcloud and Vaultwarden services and the SSL and backup blocks are the most advanced and most documented. Documenting all services and blocks will be done as I make all blocks and services use the contracts. Upstreaming changes is also on the roadmap. Check the [issues][] and the [milestones]() to see planned work. Feel free to add more or to contribute! [issues]: (https://github.com/ibizaman/selfhostblocks/issues) [milestones]: https://github.com/ibizaman/selfhostblocks/milestones All blocks and services have NixOS tests. Also, I am personally using all the blocks and services in this project, so they do work to some extent. ## Community {#preface-community} This project has been the main focus of my (non work) life for the past 3 year now and I intend to continue working on this for a long time. All issues and PRs are welcome: - Use this project. Something does not make sense? Something's not working? - Documentation. Something is not clear? - New services. Have one of your preferred service not integrated yet? - Better patterns. See something weird in the code? For PRs, if they are substantial changes, please open an issue to discuss the details first. More details in [the contributing section](https://shb.skarabox.com/contributing.html) of the manual. Issues that are being worked on are labeled with the [in progress][] label. Before starting work on those, you might want to talk about it in the issue tracker or in the [matrix][] channel. The prioritized issues are those belonging to the [next milestone][milestone]. Those issues are not set in stone and I'd be very happy to solve an issue an user has before scratching my own itch. [in progress]: https://github.com/ibizaman/selfhostblocks/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22in%20progress%22 [matrix]: https://matrix.to/#/%23selfhostblocks%3Amatrix.org [milestone]: https://github.com/ibizaman/selfhostblocks/milestones One aspect that's close to my heart is I intent to make SelfHostBlocks the lightest layer on top of nixpkgs as possible. I want to upstream as much as possible. I will still take some time to experiment here but when I'm satisfied with how things look, I'll upstream changes. ## Funding {#preface-funding} I was lucky to [obtain a grant][nlnet] from NlNet which is an European fund, under [NGI Zero Core][NGI0], to work on this project. This also funds the contracts RFC. Go apply for a grant too! [nlnet]: https://nlnet.nl/project/SelfHostBlocks [NGI0]: https://nlnet.nl/core/ ## License {#preface-license} I'm following the [Nextcloud](https://github.com/nextcloud/server) license which is AGPLv3. See [this article](https://www.fsf.org/bulletin/2021/fall/the-fundamentals-of-the-agplv3) from the FSF that explains what this license adds to the GPL one. ================================================ FILE: docs/recipes/dnsServer.md ================================================ # Self-Host a DNS server {#recipes-dnsServer} This recipe will show how to setup [dnsmasq][] as a local DNS server that forwards all queries to your own domain `example.com` to a local IP - your server running SelfHostBlocks for example. [dnsmasq]: https://dnsmasq.org/doc.html Other DNS queries will be forwarded to an external DNS server using [DNSSEC][] to encrypt your queries. [DNSSEC]: https://en.wikipedia.org/wiki/Domain_Name_System_Security_Extensions For this to work, you must configure the DHCP server of your network to set the DNS server to the IP of the host where the DNS server is running. Usually, your ISP's router can do this but probably easier is to disable completely that DHCP server and also self-host the DHCP server. This recipe shows how to do that too. ## Why {#recipes-dnsServer-why} _You want to hide your DNS queries from your ISP or other prying eyes._ Even if you use HTTPS to access an URL, DNS queries are by default made in plain text. Crazy, right? So, even if the actual communication is encrypted, everyone can see which site you're trying to access. Using DNSSEC means encrypting the traffic to your preferred external DNS server. Of course, that server will see what domain names you're trying to resolve, but at least intermediary hops will not be able to anymore. _You want more control on which DNS queries can be made._ Self-hosting your own DNS server means you can block some domains or subdomains. This is done in practice by instructing your DNS server to fail resolving some domains or subdomains. Want to block Facebook for every host in the house? That's the way to go. Some routers allow this level of fine-tuning but if not, self-hosting your own DNS server is the way to go. ## Drawbacks {#recipes-dnsServer-drawbacks} Although it has some nice advantages, self-hosting your own DNS server has one major drawback: if it goes down, the whole household will be impacted. By experience, it takes up to 5 minutes for others to notice something is wrong with internet. So be wary when you deploy a new config. ## Recipe {#recipes-dnsServer-recipe} The following snippet: - Opens UDP port 53 in the firewall which is the ubiquitous (and hardcoded, crazy I know) port for DNS queries. - Disables the default DNS resolver. - Sets up dnsmasq as the DNS server. - Optionally sets up dnsmasq as the DHCP server. - Answers all DNS requests to your domain with the internal IP of the server. - Forwards all other DNS requests to an external DNS server using DNSSEC. This is done using [stubby][]. [stubby]: https://dnsprivacy.org/dns_privacy_daemon_-_stubby/ For more information about options, read the dnsmasq [manual][]. [manual]: https://dnsmasq.org/docs/dnsmasq-man.html ```nix let # Replace these values with what matches your network. domain = "example.com"; serverIP = "192.168.1.30"; # This port is used internally for dnsmasq to talk to stubby on the loopback interface. # Only change this if that port is already taken. stubbyPort = 53000; in { networking.firewall.allowedUDPPorts = [ 53 ]; services.resolved.enable = false; services.dnsmasq = { enable = true; settings = { inherit domain; # Redirect queries to the stubby instance. server = [ "127.0.0.1#${stubbyPort}" "::1#${stubbyPort}" ]; # We do trust our own instance of stubby # so we can proxy DNSSEC stuff. # I'm not sure how useful this is. proxy-dnssec = true; # Log all queries. # This produces a lot of log lines # and looking at those can be scary! log-queries = true; # Do not look at /etc/resolv.conf no-resolv = true; # Do not forward externally reverse DNS lookups for internal IPs. bogus-priv = true; address = [ "/.${domain}/${serverIP}" # You can redirect anything anywhere too. "/pikvm.${domain}/192.168.1.31" ]; }; }; services.stubby = { enable = true; # It's a bit weird but default values comes from the examples settings hosted at # https://github.com/getdnsapi/stubby/blob/develop/stubby.yml.example settings = pkgs.stubby.passthru.settingsExample // { listen_addresses = [ "127.0.0.1@${stubbyPort}" "0::1@${stubbyPort}" ]; # For more example of good DNS resolvers, # head to https://dnsprivacy.org/public_resolvers/ # # The digest comes from https://nixos.wiki/wiki/Encrypted_DNS#Stubby upstream_recursive_servers = [ { address_data = "9.9.9.9"; tls_auth_name = "dns.quad9.net"; tls_pubkey_pinset = [ { digest = "sha256"; value = "i2kObfz0qIKCGNWt7MjBUeSrh0Dyjb0/zWINImZES+I="; } ]; } { address_data = "149.112.112.112"; tls_auth_name = "dns.quad9.net"; tls_pubkey_pinset = [ { digest = "sha256"; value = "i2kObfz0qIKCGNWt7MjBUeSrh0Dyjb0/zWINImZES+I="; } ]; } ]; }; }; } ``` Optionally, to use dnsmasq as the DHCP server too, use the following snippet: ```nix services.dnsmasq = { settings = { # When switching DNS server, accept old leases from previous server. dhcp-authoritative = true; # Adapt to your needs # ,,, dhcp-range = "192.168.1.101,192.168.1.150,255.255.255.0,6h"; # Static DNS leases if needed. # Choose an IP outside of the DHCP range # ,,, dhcp-host = [ "12:34:56:78:9a:bc,server,192.168.1.50,infinite" ]; # Set default route to the router that can acccess the internet. dhcp-option = [ "3,192.168.1.1" ]; }; }; ``` ================================================ FILE: docs/recipes/exposeService.md ================================================ # Expose a service {#recipes-exposeService} Let's see how one can use most of the blocks provided by SelfHostBlocks to make a service accessible through a reverse proxy with LDAP and SSO integration as well as backing up this service and creating a ZFS dataset to store the service's data. We'll use an hypothetical well made service found under `services.awesome` as our example. We're purposely not using a real service to avoid needing to deal with uninteresting particularities. ## Service setup {#recipes-exposeService-service} Let's say our domain name is `example.com`, and we want to reach our service under the `awesome` subdomain: ```nix let domain = "example.com"; subdomain = "awesome"; fqdn = "${subdomain}.${domain}"; listenPort = 9000; dataDir = "/var/lib/awesome"; ldapGroup = "awesome_user"; in ``` We then `enable` the service and explicitly set the `listenPort` and `dataDir`, assuming those options exist: ```nix services.awesome = { enable = true; inherit dataDir listenPort; }; ``` ## SSL Certificate {#recipes-exposeService-ssl} Requesting an SSL certificate from Let's Encrypt is done by adding an entry to the `extraDomains` option: ```nix shb.certs.certs.letsencrypt.${domain}.extraDomains = [ fqdn ]; ``` This assumes the `shb.certs` block has been configured: ```nix shb.certs.certs.letsencrypt.${domain} = { inherit domain; group = "nginx"; reloadServices = [ "nginx.service" ]; adminEmail = "admin@${domain}"; }; ``` ## LDAP group {#recipes-exposeService-ldap} We want only users of the group `calibre_user` to be able to access this subdomain. The following snippet creates the LDAP group: ```nix shb.lldap.ensureGroups = { calibre_user = {}; }; ``` ## Reverse Proxy with Forward Auth {#recipes-exposeService-nginx} If our service does not integrate with OIDC, we can still protect it with SSO with forward authentication by letting the reverse proxy handle authentication. This is done by adding an entry to `shb.nginx.vhosts`: ```nix shb.nginx.vhosts = [ { inherit subdomain domain; ssl = config.shb.certs.certs.letsencrypt.${domain}; upstream = "http://127.0.0.1:${toString listenPort}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; autheliaRules = [{ policy = "one_factor"; subject = [ "group:${ldapGroup}" ]; }]; } ]; ``` ## ZFS support {#recipes-exposeService-zfs} If you use ZFS, you can use SelfHostBlocks to create a dataset for you: ```nix shb.zfs.datasets."safe/awesome".path = config.services.awesome.dataDir; ``` ## Debugging {#recipes-exposeService-debug} Usually, the log level of the service can be increased with some option they provide. With SelfHostBlocks, you can also introspect any HTTP service by adding an `mitmdump` instance between the reverse proxy and the `awesome` service: ```nix shb.mitmdump.awesome = { inherit listenPort; upstreamPort = listenPort + 1; }; services.awesome.listenPort = lib.mkForce (listenPort + 1); ``` This creates a `mitmdump-awesome.service` systemd service which prints the requests' and responses' headers and bodies. ## Backup {#recipes-exposeService-backup} The following snippet uses the `shb.restic` block to backup the `services.awesome.dataDir` directory: ```nix shb.restic.instances.awesome = { request.user = "awesome"; request.sourceDirectories = [ dataDir ]; settings.enable = true; settings.passphrase.result = config.shb.sops.secret.awesome.result; settings.repository.path = config.services.awesome.dataDir; }; shb.sops.secret."awesome" = { request = config.shb.restic.instances.awesome.settings.passphrase.request; }; ``` ## Impermanence {#recipes-exposeService-impermanence} To save the data folder in an impermanence setup, add: ```nix { shb.zfs.datasets."safe/awesome".path = config.services.awesome.dataDir; } ``` ## Application Dashboard {#recipes-exposeService-applicationdashboard} For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.MyServices.services.Awesome = { sortOrder = 1; dashboard.request = { externalUrl = "https://${fqdn}"; internalUrl = "http://127.0.0.1:${toString listenPort}"; }; }; } ``` ================================================ FILE: docs/recipes/serveStaticPages.md ================================================ # Serve Static Pages {#recipes-serveStaticPages} This recipe shows how to use SelfHostBlocks blocks to serve static web pages using the Nginx reverse proxy with SSL termination. In this recipe, we'll assume the pages to serve are found under the `/srv/my-website` path and will be served under the `my-website.example.com` fqdn. ```nix let name = "my-website"; subdomain = name; domain = "example.com"; fqdn = "${subdomain}.${domain}"; user = "me"; in ``` We also assume the static web pages are owned and updated by the user named `me`. ## ZFS dataset {#recipes-serveStaticPages-zfs} We can create a ZFS dataset with: ```nix shb.zfs.datasets."safe/${name}".path = "/srv/${name}"; ``` ## SSL Certificate {#recipes-serveStaticPages-ssl} Requesting an SSL certificate from Let's Encrypt is done by adding an entry to the `extraDomains` option: ```nix shb.certs.certs.letsencrypt.${domain}.extraDomains = [ fqdn ]; ``` This assumes the `shb.certs` block has been configured: ```nix shb.certs.certs.letsencrypt.${domain} = { inherit domain; group = "nginx"; reloadServices = [ "nginx.service" ]; adminEmail = "admin@${domain}"; }; ``` ## Reverse Proxy {#recipes-serveStaticPages-nginx} First, we make the parent directory owned by the user which will upload them and `nginx`: ```nix systemd.tmpfiles.rules = lib.mkBefore [ "d '/srv/${name}' 0750 ${user} nginx - -" ]; ``` Now, we can setup nginx. The following snippet serves files from the `/srv/${name}/` directory. ```nix services.nginx.enable = true; services.nginx.virtualHosts."skarabox.${domain}" = { forceSSL = true; sslCertificate = config.shb.certs.certs.letsencrypt."${domain}".paths.cert; sslCertificateKey = config.shb.certs.certs.letsencrypt."${domain}".paths.key; locations."/" = { root = "/srv/${name}/"; extraConfig = '' add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; add_header Cache-Control "max-age=604800, stale-while-revalidate=86400, stale-if-error=86400, must-revalidate, public"; ''; }; }; ``` ================================================ FILE: docs/recipes.md ================================================ # Recipes {#recipes} This section of the manual gives you easy to follow recipes for common use cases. ```{=include=} chapters html:into-file=//recipes-dnsServer.html recipes/dnsServer.md ``` ```{=include=} chapters html:into-file=//recipes-exposeService.html recipes/exposeService.md ``` ```{=include=} chapters html:into-file=//recipes-serveStaticPages.html recipes/serveStaticPages.md ``` ================================================ FILE: docs/redirects.json ================================================ { "adding-new-service-documentation": [ "service-implementation-guide.html#adding-new-service-documentation" ], "all-options": [ "options.html#all-options" ], "analyze-existing-services": [ "service-implementation-guide.html#analyze-existing-services" ], "api-health-check": [ "service-implementation-guide.html#api-health-check" ], "authentication-integration": [ "service-implementation-guide.html#authentication-integration" ], "authentication-integration-pitfalls": [ "service-implementation-guide.html#authentication-integration-pitfalls" ], "automated-redirect-generation": [ "service-implementation-guide.html#automated-redirect-generation" ], "best-practices-summary": [ "service-implementation-guide.html#best-practices-summary" ], "block-ssl": [ "blocks-ssl.html#block-ssl" ], "block-ssl-debug": [ "blocks-ssl.html#block-ssl-debug" ], "block-ssl-impl-lets-encrypt": [ "blocks-ssl.html#block-ssl-impl-lets-encrypt" ], "block-ssl-impl-self-signed": [ "blocks-ssl.html#block-ssl-impl-self-signed" ], "block-ssl-options": [ "blocks-ssl.html#block-ssl-options" ], "block-ssl-tests": [ "blocks-ssl.html#block-ssl-tests" ], "block-ssl-usage": [ "blocks-ssl.html#block-ssl-usage" ], "blocks": [ "blocks.html#blocks" ], "blocks-authelia": [ "blocks-authelia.html#blocks-authelia" ], "blocks-authelia-forward-auth": [ "blocks-authelia.html#blocks-authelia-forward-auth" ], "blocks-authelia-oidc": [ "blocks-authelia.html#blocks-authelia-oidc" ], "blocks-authelia-options": [ "blocks-authelia.html#blocks-authelia-options" ], "blocks-authelia-options-shb.authelia.autheliaUser": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.autheliaUser" ], "blocks-authelia-options-shb.authelia.dashboard": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard" ], "blocks-authelia-options-shb.authelia.dashboard.request": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.request" ], "blocks-authelia-options-shb.authelia.dashboard.request.externalUrl": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.request.externalUrl" ], "blocks-authelia-options-shb.authelia.dashboard.request.internalUrl": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.request.internalUrl" ], "blocks-authelia-options-shb.authelia.dashboard.result": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.dashboard.result" ], "blocks-authelia-options-shb.authelia.dcdomain": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.dcdomain" ], "blocks-authelia-options-shb.authelia.debug": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.debug" ], "blocks-authelia-options-shb.authelia.domain": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.domain" ], "blocks-authelia-options-shb.authelia.enable": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.enable" ], "blocks-authelia-options-shb.authelia.extraDefinitions": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.extraDefinitions" ], "blocks-authelia-options-shb.authelia.extraOidcAuthorizationPolicies": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcAuthorizationPolicies" ], "blocks-authelia-options-shb.authelia.extraOidcClaimsPolicies": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcClaimsPolicies" ], "blocks-authelia-options-shb.authelia.extraOidcScopes": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.extraOidcScopes" ], "blocks-authelia-options-shb.authelia.ldapHostname": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.ldapHostname" ], "blocks-authelia-options-shb.authelia.ldapPort": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.ldapPort" ], "blocks-authelia-options-shb.authelia.mount": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.mount" ], "blocks-authelia-options-shb.authelia.mount.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.mount.path" ], "blocks-authelia-options-shb.authelia.mountRedis": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.mountRedis" ], "blocks-authelia-options-shb.authelia.mountRedis.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.mountRedis.path" ], "blocks-authelia-options-shb.authelia.oidcClients": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients" ], "blocks-authelia-options-shb.authelia.oidcClients._.authorization_policy": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.authorization_policy" ], "blocks-authelia-options-shb.authelia.oidcClients._.claims_policy": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.claims_policy" ], "blocks-authelia-options-shb.authelia.oidcClients._.client_id": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_id" ], "blocks-authelia-options-shb.authelia.oidcClients._.client_name": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_name" ], "blocks-authelia-options-shb.authelia.oidcClients._.client_secret": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_secret" ], "blocks-authelia-options-shb.authelia.oidcClients._.client_secret.source": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_secret.source" ], "blocks-authelia-options-shb.authelia.oidcClients._.client_secret.transform": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.client_secret.transform" ], "blocks-authelia-options-shb.authelia.oidcClients._.public": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.public" ], "blocks-authelia-options-shb.authelia.oidcClients._.redirect_uris": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.redirect_uris" ], "blocks-authelia-options-shb.authelia.oidcClients._.scopes": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.oidcClients._.scopes" ], "blocks-authelia-options-shb.authelia.port": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.port" ], "blocks-authelia-options-shb.authelia.rules": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.rules" ], "blocks-authelia-options-shb.authelia.secrets": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.group": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.group" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.mode": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.mode" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.owner": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.owner" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.restartUnits": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.request.restartUnits" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCHMACSecret.result.path" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.group": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.group" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.mode": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.mode" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.owner": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.owner" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.restartUnits": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request.restartUnits" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result" ], "blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.result.path" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret.request": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.group": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.group" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.mode": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.mode" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.owner": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.owner" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.restartUnits": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.request.restartUnits" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret.result": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.result" ], "blocks-authelia-options-shb.authelia.secrets.jwtSecret.result.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.jwtSecret.result.path" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.group": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.group" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.mode": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.mode" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.owner": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.owner" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.restartUnits": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.request.restartUnits" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result" ], "blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.ldapAdminPassword.result.path" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret.request": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.group": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.group" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.mode": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.mode" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.owner": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.owner" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.restartUnits": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.request.restartUnits" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret.result": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.result" ], "blocks-authelia-options-shb.authelia.secrets.sessionSecret.result.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.sessionSecret.result.path" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.group": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.group" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.mode": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.mode" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.owner": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.owner" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.restartUnits": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.request.restartUnits" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result" ], "blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result.path": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.secrets.storageEncryptionKey.result.path" ], "blocks-authelia-options-shb.authelia.smtp": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.smtp" ], "blocks-authelia-options-shb.authelia.ssl": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl" ], "blocks-authelia-options-shb.authelia.ssl.paths": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.paths" ], "blocks-authelia-options-shb.authelia.ssl.paths.cert": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.paths.cert" ], "blocks-authelia-options-shb.authelia.ssl.paths.key": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.paths.key" ], "blocks-authelia-options-shb.authelia.ssl.systemdService": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.ssl.systemdService" ], "blocks-authelia-options-shb.authelia.subdomain": [ "blocks-authelia.html#blocks-authelia-options-shb.authelia.subdomain" ], "blocks-authelia-shb-oidc": [ "blocks-authelia.html#blocks-authelia-shb-oidc" ], "blocks-authelia-tests": [ "blocks-authelia.html#blocks-authelia-tests" ], "blocks-authelia-troubleshooting": [ "blocks-authelia.html#blocks-authelia-troubleshooting" ], "blocks-authelia-usage-configuration": [ "blocks-authelia.html#blocks-authelia-usage-configuration" ], "blocks-borgbackup": [ "blocks-borgbackup.html#blocks-borgbackup" ], "blocks-borgbackup-contract-provider": [ "blocks-borgbackup.html#blocks-borgbackup-contract-provider" ], "blocks-borgbackup-maintenance": [ "blocks-borgbackup.html#blocks-borgbackup-maintenance" ], "blocks-borgbackup-maintenance-troubleshooting": [ "blocks-borgbackup.html#blocks-borgbackup-maintenance-troubleshooting" ], "blocks-borgbackup-monitoring": [ "blocks-borgbackup.html#blocks-borgbackup-monitoring" ], "blocks-borgbackup-options": [ "blocks-borgbackup.html#blocks-borgbackup-options" ], "blocks-borgbackup-options-shb.borgbackup.borgServer": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.borgServer" ], "blocks-borgbackup-options-shb.borgbackup.databases": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.request": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupCmd": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupCmd" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupName": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.backupName" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.request.restoreCmd": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.restoreCmd" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.request.user": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.request.user" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.result": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.result" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.result.backupService": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.result.backupService" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.result.restoreScript": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.result.restoreScript" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.consistency": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.consistency" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.enable": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.enable" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.limitUploadKiBs": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.limitUploadKiBs" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.group": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.group" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.mode": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.mode" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.owner": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.owner" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.restartUnits": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.request.restartUnits" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result.path": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.passphrase.result.path" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.path": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.path" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.source": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.source" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.transform": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.secrets._name_.transform" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.timerConfig": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.repository.timerConfig" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.retention": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.retention" ], "blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.stateDir": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.databases._name_.settings.stateDir" ], "blocks-borgbackup-options-shb.borgbackup.enableDashboard": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.enableDashboard" ], "blocks-borgbackup-options-shb.borgbackup.instances": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.request": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.request.excludePatterns": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.excludePatterns" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.afterBackup": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.afterBackup" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.beforeBackup": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.hooks.beforeBackup" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.request.sourceDirectories": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.sourceDirectories" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.request.user": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.request.user" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.result": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.result" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.result.backupService": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.result.backupService" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.result.restoreScript": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.result.restoreScript" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.consistency": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.consistency" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.enable": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.enable" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.limitUploadKiBs": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.limitUploadKiBs" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.group": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.group" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.mode": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.mode" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.owner": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.owner" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.restartUnits": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.request.restartUnits" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result.path": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.passphrase.result.path" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.path": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.path" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.source": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.source" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.transform": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.secrets._name_.transform" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.timerConfig": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.repository.timerConfig" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.retention": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.retention" ], "blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.stateDir": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.instances._name_.settings.stateDir" ], "blocks-borgbackup-options-shb.borgbackup.performance": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance" ], "blocks-borgbackup-options-shb.borgbackup.performance.ioPriority": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance.ioPriority" ], "blocks-borgbackup-options-shb.borgbackup.performance.ioSchedulingClass": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance.ioSchedulingClass" ], "blocks-borgbackup-options-shb.borgbackup.performance.niceness": [ "blocks-borgbackup.html#blocks-borgbackup-options-shb.borgbackup.performance.niceness" ], "blocks-borgbackup-tests": [ "blocks-borgbackup.html#blocks-borgbackup-tests" ], "blocks-borgbackup-usage": [ "blocks-borgbackup.html#blocks-borgbackup-usage" ], "blocks-borgbackup-usage-multiple": [ "blocks-borgbackup.html#blocks-borgbackup-usage-multiple" ], "blocks-borgbackup-usage-provider-contract": [ "blocks-borgbackup.html#blocks-borgbackup-usage-provider-contract" ], "blocks-borgbackup-usage-provider-manual": [ "blocks-borgbackup.html#blocks-borgbackup-usage-provider-manual" ], "blocks-borgbackup-usage-provider-remote": [ "blocks-borgbackup.html#blocks-borgbackup-usage-provider-remote" ], "blocks-category-authentication": [ "blocks.html#blocks-category-authentication" ], "blocks-category-backup": [ "blocks.html#blocks-category-backup" ], "blocks-category-database": [ "blocks.html#blocks-category-database" ], "blocks-category-introspection": [ "blocks.html#blocks-category-introspection" ], "blocks-category-network": [ "blocks.html#blocks-category-network" ], "blocks-category-secrets": [ "blocks.html#blocks-category-secrets" ], "blocks-lldap": [ "blocks-lldap.html#blocks-lldap" ], "blocks-lldap-features": [ "blocks-lldap.html#blocks-lldap-features" ], "blocks-lldap-manage-groups": [ "blocks-lldap.html#blocks-lldap-manage-groups" ], "blocks-lldap-manage-users": [ "blocks-lldap.html#blocks-lldap-manage-users" ], "blocks-lldap-options": [ "blocks-lldap.html#blocks-lldap-options" ], "blocks-lldap-options-shb.lldap.backup": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup" ], "blocks-lldap-options-shb.lldap.backup.request": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request" ], "blocks-lldap-options-shb.lldap.backup.request.excludePatterns": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.excludePatterns" ], "blocks-lldap-options-shb.lldap.backup.request.hooks": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.hooks" ], "blocks-lldap-options-shb.lldap.backup.request.hooks.afterBackup": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.hooks.afterBackup" ], "blocks-lldap-options-shb.lldap.backup.request.hooks.beforeBackup": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.hooks.beforeBackup" ], "blocks-lldap-options-shb.lldap.backup.request.sourceDirectories": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.sourceDirectories" ], "blocks-lldap-options-shb.lldap.backup.request.user": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.request.user" ], "blocks-lldap-options-shb.lldap.backup.result": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.result" ], "blocks-lldap-options-shb.lldap.backup.result.backupService": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.result.backupService" ], "blocks-lldap-options-shb.lldap.backup.result.restoreScript": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.backup.result.restoreScript" ], "blocks-lldap-options-shb.lldap.dashboard": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard" ], "blocks-lldap-options-shb.lldap.dashboard.request": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.request" ], "blocks-lldap-options-shb.lldap.dashboard.request.externalUrl": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.request.externalUrl" ], "blocks-lldap-options-shb.lldap.dashboard.request.internalUrl": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.request.internalUrl" ], "blocks-lldap-options-shb.lldap.dashboard.result": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.dashboard.result" ], "blocks-lldap-options-shb.lldap.dcdomain": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.dcdomain" ], "blocks-lldap-options-shb.lldap.debug": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.debug" ], "blocks-lldap-options-shb.lldap.domain": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.domain" ], "blocks-lldap-options-shb.lldap.enable": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.enable" ], "blocks-lldap-options-shb.lldap.enforceGroups": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.enforceGroups" ], "blocks-lldap-options-shb.lldap.enforceUserMemberships": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.enforceUserMemberships" ], "blocks-lldap-options-shb.lldap.enforceUsers": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.enforceUsers" ], "blocks-lldap-options-shb.lldap.ensureGroupFields": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields" ], "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.attributeType" ], "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isEditable" ], "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isList" ], "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.isVisible" ], "blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroupFields._name_.name" ], "blocks-lldap-options-shb.lldap.ensureGroups": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups" ], "blocks-lldap-options-shb.lldap.ensureGroups._name_.name": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureGroups._name_.name" ], "blocks-lldap-options-shb.lldap.ensureUserFields": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields" ], "blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.attributeType" ], "blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isEditable" ], "blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isList" ], "blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.isVisible" ], "blocks-lldap-options-shb.lldap.ensureUserFields._name_.name": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUserFields._name_.name" ], "blocks-lldap-options-shb.lldap.ensureUsers": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_file" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.avatar_url" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.displayName" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.email": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.email" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.firstName" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.gravatar_avatar" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.groups": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.groups" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.id": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.id" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.lastName" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.group" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.mode" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.owner" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.request.restartUnits" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.password.result.path" ], "blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ensureUsers._name_.weser_avatar" ], "blocks-lldap-options-shb.lldap.jwtSecret": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret" ], "blocks-lldap-options-shb.lldap.jwtSecret.request": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request" ], "blocks-lldap-options-shb.lldap.jwtSecret.request.group": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.group" ], "blocks-lldap-options-shb.lldap.jwtSecret.request.mode": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.mode" ], "blocks-lldap-options-shb.lldap.jwtSecret.request.owner": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.owner" ], "blocks-lldap-options-shb.lldap.jwtSecret.request.restartUnits": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.request.restartUnits" ], "blocks-lldap-options-shb.lldap.jwtSecret.result": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.result" ], "blocks-lldap-options-shb.lldap.jwtSecret.result.path": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.jwtSecret.result.path" ], "blocks-lldap-options-shb.lldap.ldapPort": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapPort" ], "blocks-lldap-options-shb.lldap.ldapUserPassword": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword" ], "blocks-lldap-options-shb.lldap.ldapUserPassword.request": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request" ], "blocks-lldap-options-shb.lldap.ldapUserPassword.request.group": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.group" ], "blocks-lldap-options-shb.lldap.ldapUserPassword.request.mode": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.mode" ], "blocks-lldap-options-shb.lldap.ldapUserPassword.request.owner": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.owner" ], "blocks-lldap-options-shb.lldap.ldapUserPassword.request.restartUnits": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.request.restartUnits" ], "blocks-lldap-options-shb.lldap.ldapUserPassword.result": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.result" ], "blocks-lldap-options-shb.lldap.ldapUserPassword.result.path": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ldapUserPassword.result.path" ], "blocks-lldap-options-shb.lldap.mount": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.mount" ], "blocks-lldap-options-shb.lldap.mount.path": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.mount.path" ], "blocks-lldap-options-shb.lldap.restrictAccessIPRange": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.restrictAccessIPRange" ], "blocks-lldap-options-shb.lldap.ssl": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl" ], "blocks-lldap-options-shb.lldap.ssl.paths": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.paths" ], "blocks-lldap-options-shb.lldap.ssl.paths.cert": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.paths.cert" ], "blocks-lldap-options-shb.lldap.ssl.paths.key": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.paths.key" ], "blocks-lldap-options-shb.lldap.ssl.systemdService": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.ssl.systemdService" ], "blocks-lldap-options-shb.lldap.subdomain": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.subdomain" ], "blocks-lldap-options-shb.lldap.webUIListenPort": [ "blocks-lldap.html#blocks-lldap-options-shb.lldap.webUIListenPort" ], "blocks-lldap-tests": [ "blocks-lldap.html#blocks-lldap-tests" ], "blocks-lldap-troubleshooting": [ "blocks-lldap.html#blocks-lldap-troubleshooting" ], "blocks-lldap-usage": [ "blocks-lldap.html#blocks-lldap-usage" ], "blocks-lldap-usage-applicationdashboard": [ "blocks-lldap.html#blocks-lldap-usage-applicationdashboard" ], "blocks-lldap-usage-configuration": [ "blocks-lldap.html#blocks-lldap-usage-configuration" ], "blocks-lldap-usage-restrict-access-by-ip": [ "blocks-lldap.html#blocks-lldap-usage-restrict-access-by-ip" ], "blocks-lldap-usage-ssl": [ "blocks-lldap.html#blocks-lldap-usage-ssl" ], "blocks-mitmdump": [ "blocks-mitmdump.html#blocks-mitmdump" ], "blocks-mitmdump-addons": [ "blocks-mitmdump.html#blocks-mitmdump-addons" ], "blocks-mitmdump-addons-logger": [ "blocks-mitmdump.html#blocks-mitmdump-addons-logger" ], "blocks-mitmdump-example": [ "blocks-mitmdump.html#blocks-mitmdump-example" ], "blocks-mitmdump-options": [ "blocks-mitmdump.html#blocks-mitmdump-options" ], "blocks-mitmdump-options-shb.mitmdump.addons": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.addons" ], "blocks-mitmdump-options-shb.mitmdump.instances": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.after": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.after" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.enabledAddons": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.enabledAddons" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.extraArgs": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.extraArgs" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.listenHost": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.listenHost" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.listenPort": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.listenPort" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.package": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.package" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.serviceName": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.serviceName" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamHost": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamHost" ], "blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamPort": [ "blocks-mitmdump.html#blocks-mitmdump-options-shb.mitmdump.instances._name_.upstreamPort" ], "blocks-mitmdump-tests": [ "blocks-mitmdump.html#blocks-mitmdump-tests" ], "blocks-mitmdump-usage": [ "blocks-mitmdump.html#blocks-mitmdump-usage" ], "blocks-mitmdump-usage-anywhere": [ "blocks-mitmdump.html#blocks-mitmdump-usage-anywhere" ], "blocks-mitmdump-usage-https": [ "blocks-mitmdump.html#blocks-mitmdump-usage-https" ], "blocks-mitmdump-usage-logging": [ "blocks-mitmdump.html#blocks-mitmdump-usage-logging" ], "blocks-monitoring": [ "blocks-monitoring.html#blocks-monitoring" ], "blocks-monitoring-backup": [ "blocks-monitoring.html#blocks-monitoring-backup" ], "blocks-monitoring-backup-alerts": [ "blocks-monitoring.html#blocks-monitoring-backup-alerts" ], "blocks-monitoring-backup-dashboard": [ "blocks-monitoring.html#blocks-monitoring-backup-dashboard" ], "blocks-monitoring-budget-alerts": [ "blocks-monitoring.html#blocks-monitoring-budget-alerts" ], "blocks-monitoring-deluge-dashboard": [ "blocks-monitoring.html#blocks-monitoring-deluge-dashboard" ], "blocks-monitoring-error-dashboard": [ "blocks-monitoring.html#blocks-monitoring-error-dashboard" ], "blocks-monitoring-nextcloud-dashboard": [ "blocks-monitoring.html#blocks-monitoring-nextcloud-dashboard" ], "blocks-monitoring-options": [ "blocks-monitoring.html#blocks-monitoring-options" ], "blocks-monitoring-options-shb.monitoring.adminPassword": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword" ], "blocks-monitoring-options-shb.monitoring.adminPassword.request": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request" ], "blocks-monitoring-options-shb.monitoring.adminPassword.request.group": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.group" ], "blocks-monitoring-options-shb.monitoring.adminPassword.request.mode": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.mode" ], "blocks-monitoring-options-shb.monitoring.adminPassword.request.owner": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.owner" ], "blocks-monitoring-options-shb.monitoring.adminPassword.request.restartUnits": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.request.restartUnits" ], "blocks-monitoring-options-shb.monitoring.adminPassword.result": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.result" ], "blocks-monitoring-options-shb.monitoring.adminPassword.result.path": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.adminPassword.result.path" ], "blocks-monitoring-options-shb.monitoring.contactPoints": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.contactPoints" ], "blocks-monitoring-options-shb.monitoring.dashboard": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard" ], "blocks-monitoring-options-shb.monitoring.dashboard.request": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.request" ], "blocks-monitoring-options-shb.monitoring.dashboard.request.externalUrl": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.request.externalUrl" ], "blocks-monitoring-options-shb.monitoring.dashboard.request.internalUrl": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.request.internalUrl" ], "blocks-monitoring-options-shb.monitoring.dashboard.result": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboard.result" ], "blocks-monitoring-options-shb.monitoring.dashboards": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.dashboards" ], "blocks-monitoring-options-shb.monitoring.debugLog": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.debugLog" ], "blocks-monitoring-options-shb.monitoring.domain": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.domain" ], "blocks-monitoring-options-shb.monitoring.enable": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.enable" ], "blocks-monitoring-options-shb.monitoring.grafanaPort": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.grafanaPort" ], "blocks-monitoring-options-shb.monitoring.ldap": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ldap" ], "blocks-monitoring-options-shb.monitoring.ldap.adminGroup": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ldap.adminGroup" ], "blocks-monitoring-options-shb.monitoring.ldap.userGroup": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ldap.userGroup" ], "blocks-monitoring-options-shb.monitoring.lokiMajorVersion": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.lokiMajorVersion" ], "blocks-monitoring-options-shb.monitoring.lokiPort": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.lokiPort" ], "blocks-monitoring-options-shb.monitoring.orgId": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.orgId" ], "blocks-monitoring-options-shb.monitoring.prometheusPort": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.prometheusPort" ], "blocks-monitoring-options-shb.monitoring.scrutiny.dashboard": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard" ], "blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request" ], "blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.externalUrl": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.externalUrl" ], "blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.internalUrl": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.request.internalUrl" ], "blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.result": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.dashboard.result" ], "blocks-monitoring-options-shb.monitoring.scrutiny.enable": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.enable" ], "blocks-monitoring-options-shb.monitoring.scrutiny.subdomain": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.scrutiny.subdomain" ], "blocks-monitoring-options-shb.monitoring.secretKey": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey" ], "blocks-monitoring-options-shb.monitoring.secretKey.request": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request" ], "blocks-monitoring-options-shb.monitoring.secretKey.request.group": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.group" ], "blocks-monitoring-options-shb.monitoring.secretKey.request.mode": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.mode" ], "blocks-monitoring-options-shb.monitoring.secretKey.request.owner": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.owner" ], "blocks-monitoring-options-shb.monitoring.secretKey.request.restartUnits": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.request.restartUnits" ], "blocks-monitoring-options-shb.monitoring.secretKey.result": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.result" ], "blocks-monitoring-options-shb.monitoring.secretKey.result.path": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.secretKey.result.path" ], "blocks-monitoring-options-shb.monitoring.smtp": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp" ], "blocks-monitoring-options-shb.monitoring.smtp.from_address": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.from_address" ], "blocks-monitoring-options-shb.monitoring.smtp.from_name": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.from_name" ], "blocks-monitoring-options-shb.monitoring.smtp.host": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.host" ], "blocks-monitoring-options-shb.monitoring.smtp.passwordFile": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.passwordFile" ], "blocks-monitoring-options-shb.monitoring.smtp.port": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.port" ], "blocks-monitoring-options-shb.monitoring.smtp.username": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.smtp.username" ], "blocks-monitoring-options-shb.monitoring.ssl": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl" ], "blocks-monitoring-options-shb.monitoring.ssl.paths": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.paths" ], "blocks-monitoring-options-shb.monitoring.ssl.paths.cert": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.paths.cert" ], "blocks-monitoring-options-shb.monitoring.ssl.paths.key": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.paths.key" ], "blocks-monitoring-options-shb.monitoring.ssl.systemdService": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.ssl.systemdService" ], "blocks-monitoring-options-shb.monitoring.sso": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso" ], "blocks-monitoring-options-shb.monitoring.sso.authEndpoint": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.authEndpoint" ], "blocks-monitoring-options-shb.monitoring.sso.authorization_policy": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.authorization_policy" ], "blocks-monitoring-options-shb.monitoring.sso.clientID": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.clientID" ], "blocks-monitoring-options-shb.monitoring.sso.enable": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.enable" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.group": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.group" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.mode": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.mode" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.owner": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.owner" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.restartUnits": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.request.restartUnits" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result.path": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecret.result.path" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.group": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.group" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.mode": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.mode" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.owner": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.owner" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.restartUnits": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.request.restartUnits" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result" ], "blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result.path": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.sso.sharedSecretForAuthelia.result.path" ], "blocks-monitoring-options-shb.monitoring.subdomain": [ "blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.subdomain" ], "blocks-monitoring-performance-dashboard": [ "blocks-monitoring.html#blocks-monitoring-performance-dashboard" ], "blocks-monitoring-provisioning": [ "blocks-monitoring.html#blocks-monitoring-provisioning" ], "blocks-monitoring-ssl": [ "blocks-monitoring.html#blocks-monitoring-ssl" ], "blocks-monitoring-ssl-alerts": [ "blocks-monitoring.html#blocks-monitoring-ssl-alerts" ], "blocks-monitoring-ssl-dashboard": [ "blocks-monitoring.html#blocks-monitoring-ssl-dashboard" ], "blocks-monitoring-usage": [ "blocks-monitoring.html#blocks-monitoring-usage" ], "blocks-monitoring-usage-applicationdashboard": [ "blocks-monitoring.html#blocks-monitoring-usage-applicationdashboard" ], "blocks-monitoring-usage-configuration": [ "blocks-monitoring.html#blocks-monitoring-usage-configuration" ], "blocks-monitoring-usage-log-optimization": [ "blocks-monitoring.html#blocks-monitoring-usage-log-optimization" ], "blocks-monitoring-usage-scrutiny": [ "blocks-monitoring.html#blocks-monitoring-usage-scrutiny" ], "blocks-monitoring-usage-smtp": [ "blocks-monitoring.html#blocks-monitoring-usage-smtp" ], "blocks-nginx": [ "blocks-nginx.html#blocks-nginx" ], "blocks-nginx-options": [ "blocks-nginx.html#blocks-nginx-options" ], "blocks-nginx-options-shb.nginx.accessLog": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.accessLog" ], "blocks-nginx-options-shb.nginx.debugLog": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.debugLog" ], "blocks-nginx-options-shb.nginx.vhosts": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts" ], "blocks-nginx-options-shb.nginx.vhosts._.authEndpoint": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.authEndpoint" ], "blocks-nginx-options-shb.nginx.vhosts._.autheliaRules": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.autheliaRules" ], "blocks-nginx-options-shb.nginx.vhosts._.domain": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.domain" ], "blocks-nginx-options-shb.nginx.vhosts._.extraConfig": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.extraConfig" ], "blocks-nginx-options-shb.nginx.vhosts._.phpForwardAuth": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.phpForwardAuth" ], "blocks-nginx-options-shb.nginx.vhosts._.ssl": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl" ], "blocks-nginx-options-shb.nginx.vhosts._.ssl.paths": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.paths" ], "blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.cert": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.cert" ], "blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.key": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.paths.key" ], "blocks-nginx-options-shb.nginx.vhosts._.ssl.systemdService": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.ssl.systemdService" ], "blocks-nginx-options-shb.nginx.vhosts._.subdomain": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.subdomain" ], "blocks-nginx-options-shb.nginx.vhosts._.upstream": [ "blocks-nginx.html#blocks-nginx-options-shb.nginx.vhosts._.upstream" ], "blocks-nginx-usage": [ "blocks-nginx.html#blocks-nginx-usage" ], "blocks-nginx-usage-accesslog": [ "blocks-nginx.html#blocks-nginx-usage-accesslog" ], "blocks-nginx-usage-debuglog": [ "blocks-nginx.html#blocks-nginx-usage-debuglog" ], "blocks-nginx-usage-extraconfig": [ "blocks-nginx.html#blocks-nginx-usage-extraconfig" ], "blocks-nginx-usage-forwardauth": [ "blocks-nginx.html#blocks-nginx-usage-forwardauth" ], "blocks-nginx-usage-shbforwardauth": [ "blocks-nginx.html#blocks-nginx-usage-shbforwardauth" ], "blocks-nginx-usage-ssl": [ "blocks-nginx.html#blocks-nginx-usage-ssl" ], "blocks-nginx-usage-upstream": [ "blocks-nginx.html#blocks-nginx-usage-upstream" ], "blocks-postgresql": [ "blocks-postgresql.html#blocks-postgresql" ], "blocks-postgresql-contract-databasebackup": [ "blocks-postgresql.html#blocks-postgresql-contract-databasebackup" ], "blocks-postgresql-contract-databasebackup-all": [ "blocks-postgresql.html#blocks-postgresql-contract-databasebackup-all" ], "blocks-postgresql-ensures": [ "blocks-postgresql.html#blocks-postgresql-ensures" ], "blocks-postgresql-options": [ "blocks-postgresql.html#blocks-postgresql-options" ], "blocks-postgresql-options-shb.postgresql.databasebackup": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup" ], "blocks-postgresql-options-shb.postgresql.databasebackup.request": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request" ], "blocks-postgresql-options-shb.postgresql.databasebackup.request.backupCmd": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.backupCmd" ], "blocks-postgresql-options-shb.postgresql.databasebackup.request.backupName": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.backupName" ], "blocks-postgresql-options-shb.postgresql.databasebackup.request.restoreCmd": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.restoreCmd" ], "blocks-postgresql-options-shb.postgresql.databasebackup.request.user": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.request.user" ], "blocks-postgresql-options-shb.postgresql.databasebackup.result": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.result" ], "blocks-postgresql-options-shb.postgresql.databasebackup.result.backupService": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.result.backupService" ], "blocks-postgresql-options-shb.postgresql.databasebackup.result.restoreScript": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.databasebackup.result.restoreScript" ], "blocks-postgresql-options-shb.postgresql.debug": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.debug" ], "blocks-postgresql-options-shb.postgresql.enableTCPIP": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.enableTCPIP" ], "blocks-postgresql-options-shb.postgresql.ensures": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures" ], "blocks-postgresql-options-shb.postgresql.ensures._.database": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures._.database" ], "blocks-postgresql-options-shb.postgresql.ensures._.passwordFile": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures._.passwordFile" ], "blocks-postgresql-options-shb.postgresql.ensures._.username": [ "blocks-postgresql.html#blocks-postgresql-options-shb.postgresql.ensures._.username" ], "blocks-postgresql-tests": [ "blocks-postgresql.html#blocks-postgresql-tests" ], "blocks-postgresql-usage": [ "blocks-postgresql.html#blocks-postgresql-usage" ], "blocks-restic": [ "blocks-restic.html#blocks-restic" ], "blocks-restic-contract-provider": [ "blocks-restic.html#blocks-restic-contract-provider" ], "blocks-restic-maintenance": [ "blocks-restic.html#blocks-restic-maintenance" ], "blocks-restic-maintenance-troubleshooting": [ "blocks-restic.html#blocks-restic-maintenance-troubleshooting" ], "blocks-restic-monitoring": [ "blocks-restic.html#blocks-restic-monitoring" ], "blocks-restic-options": [ "blocks-restic.html#blocks-restic-options" ], "blocks-restic-options-shb.restic.databases": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases" ], "blocks-restic-options-shb.restic.databases._name_.request": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request" ], "blocks-restic-options-shb.restic.databases._name_.request.backupCmd": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.backupCmd" ], "blocks-restic-options-shb.restic.databases._name_.request.backupName": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.backupName" ], "blocks-restic-options-shb.restic.databases._name_.request.restoreCmd": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.restoreCmd" ], "blocks-restic-options-shb.restic.databases._name_.request.user": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.request.user" ], "blocks-restic-options-shb.restic.databases._name_.result": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.result" ], "blocks-restic-options-shb.restic.databases._name_.result.backupService": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.result.backupService" ], "blocks-restic-options-shb.restic.databases._name_.result.restoreScript": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.result.restoreScript" ], "blocks-restic-options-shb.restic.databases._name_.settings": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings" ], "blocks-restic-options-shb.restic.databases._name_.settings.enable": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.enable" ], "blocks-restic-options-shb.restic.databases._name_.settings.limitDownloadKiBs": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.limitDownloadKiBs" ], "blocks-restic-options-shb.restic.databases._name_.settings.limitUploadKiBs": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.limitUploadKiBs" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.group": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.group" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.mode": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.mode" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.owner": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.owner" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.restartUnits": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.request.restartUnits" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result" ], "blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result.path": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.passphrase.result.path" ], "blocks-restic-options-shb.restic.databases._name_.settings.repository": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository" ], "blocks-restic-options-shb.restic.databases._name_.settings.repository.path": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.path" ], "blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets" ], "blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.source": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.source" ], "blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.transform": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.secrets._name_.transform" ], "blocks-restic-options-shb.restic.databases._name_.settings.repository.timerConfig": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.repository.timerConfig" ], "blocks-restic-options-shb.restic.databases._name_.settings.retention": [ "blocks-restic.html#blocks-restic-options-shb.restic.databases._name_.settings.retention" ], "blocks-restic-options-shb.restic.enableDashboard": [ "blocks-restic.html#blocks-restic-options-shb.restic.enableDashboard" ], "blocks-restic-options-shb.restic.instances": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances" ], "blocks-restic-options-shb.restic.instances._name_.request": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request" ], "blocks-restic-options-shb.restic.instances._name_.request.excludePatterns": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.excludePatterns" ], "blocks-restic-options-shb.restic.instances._name_.request.hooks": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.hooks" ], "blocks-restic-options-shb.restic.instances._name_.request.hooks.afterBackup": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.hooks.afterBackup" ], "blocks-restic-options-shb.restic.instances._name_.request.hooks.beforeBackup": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.hooks.beforeBackup" ], "blocks-restic-options-shb.restic.instances._name_.request.sourceDirectories": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.sourceDirectories" ], "blocks-restic-options-shb.restic.instances._name_.request.user": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.request.user" ], "blocks-restic-options-shb.restic.instances._name_.result": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.result" ], "blocks-restic-options-shb.restic.instances._name_.result.backupService": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.result.backupService" ], "blocks-restic-options-shb.restic.instances._name_.result.restoreScript": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.result.restoreScript" ], "blocks-restic-options-shb.restic.instances._name_.settings": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings" ], "blocks-restic-options-shb.restic.instances._name_.settings.enable": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.enable" ], "blocks-restic-options-shb.restic.instances._name_.settings.limitDownloadKiBs": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.limitDownloadKiBs" ], "blocks-restic-options-shb.restic.instances._name_.settings.limitUploadKiBs": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.limitUploadKiBs" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.group": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.group" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.mode": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.mode" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.owner": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.owner" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.restartUnits": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.request.restartUnits" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result" ], "blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result.path": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.passphrase.result.path" ], "blocks-restic-options-shb.restic.instances._name_.settings.repository": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository" ], "blocks-restic-options-shb.restic.instances._name_.settings.repository.path": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.path" ], "blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets" ], "blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.source": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.source" ], "blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.transform": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.secrets._name_.transform" ], "blocks-restic-options-shb.restic.instances._name_.settings.repository.timerConfig": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.repository.timerConfig" ], "blocks-restic-options-shb.restic.instances._name_.settings.retention": [ "blocks-restic.html#blocks-restic-options-shb.restic.instances._name_.settings.retention" ], "blocks-restic-options-shb.restic.performance": [ "blocks-restic.html#blocks-restic-options-shb.restic.performance" ], "blocks-restic-options-shb.restic.performance.ioPriority": [ "blocks-restic.html#blocks-restic-options-shb.restic.performance.ioPriority" ], "blocks-restic-options-shb.restic.performance.ioSchedulingClass": [ "blocks-restic.html#blocks-restic-options-shb.restic.performance.ioSchedulingClass" ], "blocks-restic-options-shb.restic.performance.niceness": [ "blocks-restic.html#blocks-restic-options-shb.restic.performance.niceness" ], "blocks-restic-tests": [ "blocks-restic.html#blocks-restic-tests" ], "blocks-restic-usage": [ "blocks-restic.html#blocks-restic-usage" ], "blocks-restic-usage-multiple": [ "blocks-restic.html#blocks-restic-usage-multiple" ], "blocks-restic-usage-provider-contract": [ "blocks-restic.html#blocks-restic-usage-provider-contract" ], "blocks-restic-usage-provider-manual": [ "blocks-restic.html#blocks-restic-usage-provider-manual" ], "blocks-restic-usage-provider-remote": [ "blocks-restic.html#blocks-restic-usage-provider-remote" ], "blocks-sops": [ "blocks-sops.html#blocks-sops" ], "blocks-sops-contract-provider": [ "blocks-sops.html#blocks-sops-contract-provider" ], "blocks-sops-options": [ "blocks-sops.html#blocks-sops-options" ], "blocks-sops-options-shb.sops.secret": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret" ], "blocks-sops-options-shb.sops.secret._name_.request": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request" ], "blocks-sops-options-shb.sops.secret._name_.request.group": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.group" ], "blocks-sops-options-shb.sops.secret._name_.request.mode": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.mode" ], "blocks-sops-options-shb.sops.secret._name_.request.owner": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.owner" ], "blocks-sops-options-shb.sops.secret._name_.request.restartUnits": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.request.restartUnits" ], "blocks-sops-options-shb.sops.secret._name_.result": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.result" ], "blocks-sops-options-shb.sops.secret._name_.result.path": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.result.path" ], "blocks-sops-options-shb.sops.secret._name_.settings": [ "blocks-sops.html#blocks-sops-options-shb.sops.secret._name_.settings" ], "blocks-sops-usage": [ "blocks-sops.html#blocks-sops-usage" ], "blocks-sops-usage-manual": [ "blocks-sops.html#blocks-sops-usage-manual" ], "blocks-sops-usage-requester": [ "blocks-sops.html#blocks-sops-usage-requester" ], "blocks-ssl-debug-lets-encrypt": [ "blocks-ssl.html#blocks-ssl-debug-lets-encrypt" ], "blocks-ssl-monitoring": [ "blocks-ssl.html#blocks-ssl-monitoring" ], "blocks-ssl-options-shb.certs.cas.selfsigned": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned" ], "blocks-ssl-options-shb.certs.cas.selfsigned._name_.name": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.name" ], "blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths" ], "blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.cert": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.cert" ], "blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.key": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.paths.key" ], "blocks-ssl-options-shb.certs.cas.selfsigned._name_.systemdService": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned._name_.systemdService" ], "blocks-ssl-options-shb.certs.certs.letsencrypt": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.additionalEnvironment": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.additionalEnvironment" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.adminEmail": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.adminEmail" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.afterAndWants": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.afterAndWants" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.credentialsFile": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.credentialsFile" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.debug": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.debug" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsProvider": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsProvider" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsResolver": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.dnsResolver" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.domain": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.domain" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.extraDomains": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.extraDomains" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.group": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.group" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.makeAvailableToUser": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.makeAvailableToUser" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.cert": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.cert" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.key": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.paths.key" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.reloadServices": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.reloadServices" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.stagingServer": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.stagingServer" ], "blocks-ssl-options-shb.certs.certs.letsencrypt._name_.systemdService": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt._name_.systemdService" ], "blocks-ssl-options-shb.certs.certs.selfsigned": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.cert": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.cert" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.key": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.paths.key" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.systemdService": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.ca.systemdService" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.domain": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.domain" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.extraDomains": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.extraDomains" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.group": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.group" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.cert": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.cert" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.key": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.paths.key" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.reloadServices": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.reloadServices" ], "blocks-ssl-options-shb.certs.certs.selfsigned._name_.systemdService": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned._name_.systemdService" ], "blocks-ssl-options-shb.certs.enableDashboard": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.enableDashboard" ], "blocks-ssl-options-shb.certs.systemdService": [ "blocks-ssl.html#blocks-ssl-options-shb.certs.systemdService" ], "build-time-validation": [ "service-implementation-guide.html#build-time-validation" ], "check-nixos-integration": [ "service-implementation-guide.html#check-nixos-integration" ], "common-pitfalls-and-solutions": [ "service-implementation-guide.html#common-pitfalls-and-solutions" ], "complete-shb-service": [ "service-implementation-guide.html#complete-shb-service" ], "complete-workflow": [ "service-implementation-guide.html#complete-workflow" ], "configuration-issues": [ "service-implementation-guide.html#configuration-issues" ], "configuration-management": [ "service-implementation-guide.html#configuration-management" ], "contract-backup": [ "contracts-backup.html#contract-backup" ], "contract-backup-options": [ "contracts-backup.html#contract-backup-options" ], "contract-backup-providers": [ "contracts-backup.html#contract-backup-providers" ], "contract-backup-requesters": [ "contracts-backup.html#contract-backup-requesters" ], "contract-backup-usage": [ "contracts-backup.html#contract-backup-usage" ], "contract-dashboard": [ "contracts-dashboard.html#contract-dashboard" ], "contract-dashboard-options": [ "contracts-dashboard.html#contract-dashboard-options" ], "contract-dashboard-providers": [ "contracts-dashboard.html#contract-dashboard-providers" ], "contract-databasebackup": [ "contracts-databasebackup.html#contract-databasebackup" ], "contract-databasebackup-options": [ "contracts-databasebackup.html#contract-databasebackup-options" ], "contract-databasebackup-providers": [ "contracts-databasebackup.html#contract-databasebackup-providers" ], "contract-databasebackup-requesters": [ "contracts-databasebackup.html#contract-databasebackup-requesters" ], "contract-databasebackup-usage": [ "contracts-databasebackup.html#contract-databasebackup-usage" ], "contract-secret": [ "contracts-secret.html#contract-secret" ], "contract-secret-motivation": [ "contracts-secret.html#contract-secret-motivation" ], "contract-secret-options": [ "contracts-secret.html#contract-secret-options" ], "contract-secret-usage": [ "contracts-secret.html#contract-secret-usage" ], "contract-secret-usage-enduser": [ "contracts-secret.html#contract-secret-usage-enduser" ], "contract-secret-usage-provider": [ "contracts-secret.html#contract-secret-usage-provider" ], "contract-secret-usage-requester": [ "contracts-secret.html#contract-secret-usage-requester" ], "contract-ssl": [ "contracts-ssl.html#contract-ssl" ], "contract-ssl-impl-custom": [ "contracts-ssl.html#contract-ssl-impl-custom" ], "contract-ssl-impl-shb": [ "contracts-ssl.html#contract-ssl-impl-shb" ], "contract-ssl-options": [ "contracts-ssl.html#contract-ssl-options" ], "contract-ssl-usage": [ "contracts-ssl.html#contract-ssl-usage" ], "contracts": [ "contracts.html#contracts" ], "contracts-backup-options-shb.contracts.backup": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup" ], "contracts-backup-options-shb.contracts.backup.request": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.request" ], "contracts-backup-options-shb.contracts.backup.request.excludePatterns": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.excludePatterns" ], "contracts-backup-options-shb.contracts.backup.request.hooks": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.hooks" ], "contracts-backup-options-shb.contracts.backup.request.hooks.afterBackup": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.hooks.afterBackup" ], "contracts-backup-options-shb.contracts.backup.request.hooks.beforeBackup": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.hooks.beforeBackup" ], "contracts-backup-options-shb.contracts.backup.request.sourceDirectories": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.sourceDirectories" ], "contracts-backup-options-shb.contracts.backup.request.user": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.request.user" ], "contracts-backup-options-shb.contracts.backup.result": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.result" ], "contracts-backup-options-shb.contracts.backup.result.backupService": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.result.backupService" ], "contracts-backup-options-shb.contracts.backup.result.restoreScript": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.result.restoreScript" ], "contracts-backup-options-shb.contracts.backup.settings": [ "contracts-backup.html#contracts-backup-options-shb.contracts.backup.settings" ], "contracts-concept": [ "contracts.html#contracts-concept" ], "contracts-dashboard-options-shb.contracts.dashboard": [ "contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard" ], "contracts-dashboard-options-shb.contracts.dashboard.request": [ "contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.request" ], "contracts-dashboard-options-shb.contracts.dashboard.request.externalUrl": [ "contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.request.externalUrl" ], "contracts-dashboard-options-shb.contracts.dashboard.request.internalUrl": [ "contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.request.internalUrl" ], "contracts-dashboard-options-shb.contracts.dashboard.result": [ "contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.result" ], "contracts-dashboard-options-shb.contracts.dashboard.settings": [ "contracts-dashboard.html#contracts-dashboard-options-shb.contracts.dashboard.settings" ], "contracts-dashboard-usage": [ "contracts-dashboard.html#contracts-dashboard-usage" ], "contracts-databasebackup-options-shb.contracts.databasebackup": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup" ], "contracts-databasebackup-options-shb.contracts.databasebackup.request": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request" ], "contracts-databasebackup-options-shb.contracts.databasebackup.request.backupCmd": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.backupCmd" ], "contracts-databasebackup-options-shb.contracts.databasebackup.request.backupName": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.backupName" ], "contracts-databasebackup-options-shb.contracts.databasebackup.request.restoreCmd": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.restoreCmd" ], "contracts-databasebackup-options-shb.contracts.databasebackup.request.user": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.request.user" ], "contracts-databasebackup-options-shb.contracts.databasebackup.result": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.result" ], "contracts-databasebackup-options-shb.contracts.databasebackup.result.backupService": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.result.backupService" ], "contracts-databasebackup-options-shb.contracts.databasebackup.result.restoreScript": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.result.restoreScript" ], "contracts-databasebackup-options-shb.contracts.databasebackup.settings": [ "contracts-databasebackup.html#contracts-databasebackup-options-shb.contracts.databasebackup.settings" ], "contracts-nixpkgs": [ "contracts.html#contracts-nixpkgs" ], "contracts-provided": [ "contracts.html#contracts-provided" ], "contracts-schema": [ "contracts.html#contracts-schema" ], "contracts-secret-options-shb.contracts.secret": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret" ], "contracts-secret-options-shb.contracts.secret.request": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.request" ], "contracts-secret-options-shb.contracts.secret.request.group": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.group" ], "contracts-secret-options-shb.contracts.secret.request.mode": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.mode" ], "contracts-secret-options-shb.contracts.secret.request.owner": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.owner" ], "contracts-secret-options-shb.contracts.secret.request.restartUnits": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.request.restartUnits" ], "contracts-secret-options-shb.contracts.secret.result": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.result" ], "contracts-secret-options-shb.contracts.secret.result.path": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.result.path" ], "contracts-secret-options-shb.contracts.secret.settings": [ "contracts-secret.html#contracts-secret-options-shb.contracts.secret.settings" ], "contracts-ssl-options-shb.contracts.ssl": [ "contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl" ], "contracts-ssl-options-shb.contracts.ssl.paths": [ "contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.paths" ], "contracts-ssl-options-shb.contracts.ssl.paths.cert": [ "contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.paths.cert" ], "contracts-ssl-options-shb.contracts.ssl.paths.key": [ "contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.paths.key" ], "contracts-ssl-options-shb.contracts.ssl.systemdService": [ "contracts-ssl.html#contracts-ssl-options-shb.contracts.ssl.systemdService" ], "contracts-test": [ "contracts.html#contracts-test" ], "contracts-videos": [ "contracts.html#contracts-videos" ], "contracts-why": [ "contracts.html#contracts-why" ], "contributing": [ "contributing.html#contributing" ], "contributing-chat": [ "contributing.html#contributing-chat" ], "contributing-code": [ "contributing.html#contributing-code" ], "contributing-debug-tests": [ "contributing.html#contributing-debug-tests" ], "contributing-deploy-colmena": [ "contributing.html#contributing-deploy-colmena" ], "contributing-diff": [ "contributing.html#contributing-diff" ], "contributing-diff-deployed": [ "contributing.html#contributing-diff-deployed" ], "contributing-diff-full": [ "contributing.html#contributing-diff-full" ], "contributing-diff-todeploy": [ "contributing.html#contributing-diff-todeploy" ], "contributing-diff-version": [ "contributing.html#contributing-diff-version" ], "contributing-gensecret": [ "contributing.html#contributing-gensecret" ], "contributing-links": [ "contributing.html#contributing-links" ], "contributing-localversion": [ "contributing.html#contributing-localversion" ], "contributing-playwright-tests": [ "contributing.html#contributing-playwright-tests" ], "contributing-runtests": [ "contributing.html#contributing-runtests" ], "contributing-upload": [ "contributing.html#contributing-upload" ], "contributing-upload-package": [ "contributing.html#contributing-upload-package" ], "contributing-upstream": [ "contributing.html#contributing-upstream" ], "create-comprehensive-tests": [ "service-implementation-guide.html#create-comprehensive-tests" ], "create-service-documentation": [ "service-implementation-guide.html#create-service-documentation" ], "create-service-module": [ "service-implementation-guide.html#create-service-module" ], "demo-homeassistant": [ "demo-homeassistant.html#demo-homeassistant" ], "demo-homeassistant-deploy": [ "demo-homeassistant.html#demo-homeassistant-deploy" ], "demo-homeassistant-deploy-basic": [ "demo-homeassistant.html#demo-homeassistant-deploy-basic" ], "demo-homeassistant-deploy-colmena": [ "demo-homeassistant.html#demo-homeassistant-deploy-colmena" ], "demo-homeassistant-deploy-ldap": [ "demo-homeassistant.html#demo-homeassistant-deploy-ldap" ], "demo-homeassistant-deploy-nixosrebuild": [ "demo-homeassistant.html#demo-homeassistant-deploy-nixosrebuild" ], "demo-homeassistant-files": [ "demo-homeassistant.html#demo-homeassistant-files" ], "demo-homeassistant-in-more-details": [ "demo-homeassistant.html#demo-homeassistant-in-more-details" ], "demo-homeassistant-secrets": [ "demo-homeassistant.html#demo-homeassistant-secrets" ], "demo-homeassistant-tips-deploy": [ "demo-homeassistant.html#demo-homeassistant-tips-deploy" ], "demo-homeassistant-tips-public-key-necessity": [ "demo-homeassistant.html#demo-homeassistant-tips-public-key-necessity" ], "demo-homeassistant-tips-ssh": [ "demo-homeassistant.html#demo-homeassistant-tips-ssh" ], "demo-homeassistant-tips-update-demo": [ "demo-homeassistant.html#demo-homeassistant-tips-update-demo" ], "demo-homeassistant-virtual-machine": [ "demo-homeassistant.html#demo-homeassistant-virtual-machine" ], "demo-nextcloud": [ "demo-nextcloud.html#demo-nextcloud" ], "demo-nextcloud-deploy": [ "demo-nextcloud.html#demo-nextcloud-deploy" ], "demo-nextcloud-deploy-basic": [ "demo-nextcloud.html#demo-nextcloud-deploy-basic" ], "demo-nextcloud-deploy-colmena": [ "demo-nextcloud.html#demo-nextcloud-deploy-colmena" ], "demo-nextcloud-deploy-ldap": [ "demo-nextcloud.html#demo-nextcloud-deploy-ldap" ], "demo-nextcloud-deploy-nixosrebuild": [ "demo-nextcloud.html#demo-nextcloud-deploy-nixosrebuild" ], "demo-nextcloud-deploy-sso": [ "demo-nextcloud.html#demo-nextcloud-deploy-sso" ], "demo-nextcloud-tips": [ "demo-nextcloud.html#demo-nextcloud-tips" ], "demo-nextcloud-tips-deploy": [ "demo-nextcloud.html#demo-nextcloud-tips-deploy" ], "demo-nextcloud-tips-files": [ "demo-nextcloud.html#demo-nextcloud-tips-files" ], "demo-nextcloud-tips-public-key-necessity": [ "demo-nextcloud.html#demo-nextcloud-tips-public-key-necessity" ], "demo-nextcloud-tips-secrets": [ "demo-nextcloud.html#demo-nextcloud-tips-secrets" ], "demo-nextcloud-tips-ssh": [ "demo-nextcloud.html#demo-nextcloud-tips-ssh" ], "demo-nextcloud-tips-update-demo": [ "demo-nextcloud.html#demo-nextcloud-tips-update-demo" ], "demo-nextcloud-tips-virtual-machine": [ "demo-nextcloud.html#demo-nextcloud-tips-virtual-machine" ], "demos": [ "demos.html#demos" ], "external-exporter": [ "service-implementation-guide.html#external-exporter" ], "handle-unfree-dependencies": [ "service-implementation-guide.html#handle-unfree-dependencies" ], "how-redirects-work": [ "service-implementation-guide.html#how-redirects-work" ], "implementation-considerations": [ "service-implementation-guide.html#implementation-considerations" ], "implementation-steps": [ "service-implementation-guide.html#implementation-steps" ], "iterative-development-approach": [ "service-implementation-guide.html#iterative-development-approach" ], "local-testing": [ "service-implementation-guide.html#local-testing" ], "monitoring-failures": [ "service-implementation-guide.html#monitoring-failures" ], "monitoring-implementation": [ "service-implementation-guide.html#monitoring-implementation" ], "native-prometheus-metrics": [ "service-implementation-guide.html#native-prometheus-metrics" ], "nixpkgs-integration": [ "service-implementation-guide.html#nixpkgs-integration" ], "pre-implementation-research": [ "service-implementation-guide.html#pre-implementation-research" ], "preface": [ "index.html#preface" ], "preface-blocks": [ "index.html#preface-blocks" ], "preface-community": [ "index.html#preface-community" ], "preface-contracts": [ "index.html#preface-contracts" ], "preface-demos": [ "index.html#preface-demos" ], "preface-features": [ "index.html#preface-features" ], "preface-funding": [ "index.html#preface-funding" ], "preface-giants": [ "index.html#preface-giants" ], "preface-interface": [ "index.html#preface-interface" ], "preface-license": [ "index.html#preface-license" ], "preface-roadmap": [ "index.html#preface-roadmap" ], "preface-services": [ "index.html#preface-services" ], "preface-unified-interfaces": [ "index.html#preface-unified-interfaces" ], "preface-updates": [ "index.html#preface-updates" ], "preface-usage": [ "index.html#preface-usage" ], "preface-usage-installation-from-scratch": [ "index.html#preface-usage-installation-from-scratch" ], "preface-why-self-hosting": [ "index.html#preface-why-self-hosting" ], "quick-reference": [ "service-implementation-guide.html#quick-reference" ], "recipes": [ "recipes.html#recipes" ], "recipes-dnsServer": [ "recipes-dnsServer.html#recipes-dnsServer" ], "recipes-dnsServer-drawbacks": [ "recipes-dnsServer.html#recipes-dnsServer-drawbacks" ], "recipes-dnsServer-recipe": [ "recipes-dnsServer.html#recipes-dnsServer-recipe" ], "recipes-dnsServer-why": [ "recipes-dnsServer.html#recipes-dnsServer-why" ], "recipes-exposeService": [ "recipes-exposeService.html#recipes-exposeService" ], "recipes-exposeService-applicationdashboard": [ "recipes-exposeService.html#recipes-exposeService-applicationdashboard" ], "recipes-exposeService-backup": [ "recipes-exposeService.html#recipes-exposeService-backup" ], "recipes-exposeService-debug": [ "recipes-exposeService.html#recipes-exposeService-debug" ], "recipes-exposeService-impermanence": [ "recipes-exposeService.html#recipes-exposeService-impermanence" ], "recipes-exposeService-ldap": [ "recipes-exposeService.html#recipes-exposeService-ldap" ], "recipes-exposeService-nginx": [ "recipes-exposeService.html#recipes-exposeService-nginx" ], "recipes-exposeService-service": [ "recipes-exposeService.html#recipes-exposeService-service" ], "recipes-exposeService-ssl": [ "recipes-exposeService.html#recipes-exposeService-ssl" ], "recipes-exposeService-zfs": [ "recipes-exposeService.html#recipes-exposeService-zfs" ], "recipes-serveStaticPages": [ "recipes-serveStaticPages.html#recipes-serveStaticPages" ], "recipes-serveStaticPages-nginx": [ "recipes-serveStaticPages.html#recipes-serveStaticPages-nginx" ], "recipes-serveStaticPages-ssl": [ "recipes-serveStaticPages.html#recipes-serveStaticPages-ssl" ], "recipes-serveStaticPages-zfs": [ "recipes-serveStaticPages.html#recipes-serveStaticPages-zfs" ], "redirect-management": [ "service-implementation-guide.html#redirect-management" ], "redirect-patterns": [ "service-implementation-guide.html#redirect-patterns" ], "required-test-variants": [ "service-implementation-guide.html#required-test-variants" ], "resources": [ "service-implementation-guide.html#resources" ], "security-best-practices": [ "service-implementation-guide.html#security-best-practices" ], "self-host-blocks-manual": [ "index.html#self-host-blocks-manual" ], "service-implementation-guide": [ "service-implementation-guide.html#service-implementation-guide" ], "services": [ "services.html#services" ], "services-arr": [ "services-arr.html#services-arr" ], "services-arr-features": [ "services-arr.html#services-arr-features" ], "services-arr-options": [ "services-arr.html#services-arr-options" ], "services-arr-options-shb.arr.bazarr": [ "services-arr.html#services-arr-options-shb.arr.bazarr" ], "services-arr-options-shb.arr.bazarr.authEndpoint": [ "services-arr.html#services-arr-options-shb.arr.bazarr.authEndpoint" ], "services-arr-options-shb.arr.bazarr.backup": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup" ], "services-arr-options-shb.arr.bazarr.backup.request": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.request" ], "services-arr-options-shb.arr.bazarr.backup.request.excludePatterns": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.excludePatterns" ], "services-arr-options-shb.arr.bazarr.backup.request.hooks": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.hooks" ], "services-arr-options-shb.arr.bazarr.backup.request.hooks.afterBackup": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.hooks.afterBackup" ], "services-arr-options-shb.arr.bazarr.backup.request.hooks.beforeBackup": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.hooks.beforeBackup" ], "services-arr-options-shb.arr.bazarr.backup.request.sourceDirectories": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.sourceDirectories" ], "services-arr-options-shb.arr.bazarr.backup.request.user": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.request.user" ], "services-arr-options-shb.arr.bazarr.backup.result": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.result" ], "services-arr-options-shb.arr.bazarr.backup.result.backupService": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.result.backupService" ], "services-arr-options-shb.arr.bazarr.backup.result.restoreScript": [ "services-arr.html#services-arr-options-shb.arr.bazarr.backup.result.restoreScript" ], "services-arr-options-shb.arr.bazarr.dashboard": [ "services-arr.html#services-arr-options-shb.arr.bazarr.dashboard" ], "services-arr-options-shb.arr.bazarr.dashboard.request": [ "services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.request" ], "services-arr-options-shb.arr.bazarr.dashboard.request.externalUrl": [ "services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.request.externalUrl" ], "services-arr-options-shb.arr.bazarr.dashboard.request.internalUrl": [ "services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.request.internalUrl" ], "services-arr-options-shb.arr.bazarr.dashboard.result": [ "services-arr.html#services-arr-options-shb.arr.bazarr.dashboard.result" ], "services-arr-options-shb.arr.bazarr.dataDir": [ "services-arr.html#services-arr-options-shb.arr.bazarr.dataDir" ], "services-arr-options-shb.arr.bazarr.domain": [ "services-arr.html#services-arr-options-shb.arr.bazarr.domain" ], "services-arr-options-shb.arr.bazarr.enable": [ "services-arr.html#services-arr-options-shb.arr.bazarr.enable" ], "services-arr-options-shb.arr.bazarr.ldapUserGroup": [ "services-arr.html#services-arr-options-shb.arr.bazarr.ldapUserGroup" ], "services-arr-options-shb.arr.bazarr.settings": [ "services-arr.html#services-arr-options-shb.arr.bazarr.settings" ], "services-arr-options-shb.arr.bazarr.settings.ApiKey": [ "services-arr.html#services-arr-options-shb.arr.bazarr.settings.ApiKey" ], "services-arr-options-shb.arr.bazarr.settings.ApiKey.source": [ "services-arr.html#services-arr-options-shb.arr.bazarr.settings.ApiKey.source" ], "services-arr-options-shb.arr.bazarr.settings.ApiKey.transform": [ "services-arr.html#services-arr-options-shb.arr.bazarr.settings.ApiKey.transform" ], "services-arr-options-shb.arr.bazarr.settings.LogLevel": [ "services-arr.html#services-arr-options-shb.arr.bazarr.settings.LogLevel" ], "services-arr-options-shb.arr.bazarr.settings.Port": [ "services-arr.html#services-arr-options-shb.arr.bazarr.settings.Port" ], "services-arr-options-shb.arr.bazarr.ssl": [ "services-arr.html#services-arr-options-shb.arr.bazarr.ssl" ], "services-arr-options-shb.arr.bazarr.ssl.paths": [ "services-arr.html#services-arr-options-shb.arr.bazarr.ssl.paths" ], "services-arr-options-shb.arr.bazarr.ssl.paths.cert": [ "services-arr.html#services-arr-options-shb.arr.bazarr.ssl.paths.cert" ], "services-arr-options-shb.arr.bazarr.ssl.paths.key": [ "services-arr.html#services-arr-options-shb.arr.bazarr.ssl.paths.key" ], "services-arr-options-shb.arr.bazarr.ssl.systemdService": [ "services-arr.html#services-arr-options-shb.arr.bazarr.ssl.systemdService" ], "services-arr-options-shb.arr.bazarr.subdomain": [ "services-arr.html#services-arr-options-shb.arr.bazarr.subdomain" ], "services-arr-options-shb.arr.jackett": [ "services-arr.html#services-arr-options-shb.arr.jackett" ], "services-arr-options-shb.arr.jackett.authEndpoint": [ "services-arr.html#services-arr-options-shb.arr.jackett.authEndpoint" ], "services-arr-options-shb.arr.jackett.backup": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup" ], "services-arr-options-shb.arr.jackett.backup.request": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.request" ], "services-arr-options-shb.arr.jackett.backup.request.excludePatterns": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.request.excludePatterns" ], "services-arr-options-shb.arr.jackett.backup.request.hooks": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.request.hooks" ], "services-arr-options-shb.arr.jackett.backup.request.hooks.afterBackup": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.request.hooks.afterBackup" ], "services-arr-options-shb.arr.jackett.backup.request.hooks.beforeBackup": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.request.hooks.beforeBackup" ], "services-arr-options-shb.arr.jackett.backup.request.sourceDirectories": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.request.sourceDirectories" ], "services-arr-options-shb.arr.jackett.backup.request.user": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.request.user" ], "services-arr-options-shb.arr.jackett.backup.result": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.result" ], "services-arr-options-shb.arr.jackett.backup.result.backupService": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.result.backupService" ], "services-arr-options-shb.arr.jackett.backup.result.restoreScript": [ "services-arr.html#services-arr-options-shb.arr.jackett.backup.result.restoreScript" ], "services-arr-options-shb.arr.jackett.dashboard": [ "services-arr.html#services-arr-options-shb.arr.jackett.dashboard" ], "services-arr-options-shb.arr.jackett.dashboard.request": [ "services-arr.html#services-arr-options-shb.arr.jackett.dashboard.request" ], "services-arr-options-shb.arr.jackett.dashboard.request.externalUrl": [ "services-arr.html#services-arr-options-shb.arr.jackett.dashboard.request.externalUrl" ], "services-arr-options-shb.arr.jackett.dashboard.request.internalUrl": [ "services-arr.html#services-arr-options-shb.arr.jackett.dashboard.request.internalUrl" ], "services-arr-options-shb.arr.jackett.dashboard.result": [ "services-arr.html#services-arr-options-shb.arr.jackett.dashboard.result" ], "services-arr-options-shb.arr.jackett.dataDir": [ "services-arr.html#services-arr-options-shb.arr.jackett.dataDir" ], "services-arr-options-shb.arr.jackett.domain": [ "services-arr.html#services-arr-options-shb.arr.jackett.domain" ], "services-arr-options-shb.arr.jackett.enable": [ "services-arr.html#services-arr-options-shb.arr.jackett.enable" ], "services-arr-options-shb.arr.jackett.ldapUserGroup": [ "services-arr.html#services-arr-options-shb.arr.jackett.ldapUserGroup" ], "services-arr-options-shb.arr.jackett.settings": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings" ], "services-arr-options-shb.arr.jackett.settings.ApiKey": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.ApiKey" ], "services-arr-options-shb.arr.jackett.settings.ApiKey.source": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.ApiKey.source" ], "services-arr-options-shb.arr.jackett.settings.ApiKey.transform": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.ApiKey.transform" ], "services-arr-options-shb.arr.jackett.settings.FlareSolverrUrl": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.FlareSolverrUrl" ], "services-arr-options-shb.arr.jackett.settings.OmdbApiKey": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.OmdbApiKey" ], "services-arr-options-shb.arr.jackett.settings.OmdbApiKey.source": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.OmdbApiKey.source" ], "services-arr-options-shb.arr.jackett.settings.OmdbApiKey.transform": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.OmdbApiKey.transform" ], "services-arr-options-shb.arr.jackett.settings.Port": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.Port" ], "services-arr-options-shb.arr.jackett.settings.ProxyPort": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.ProxyPort" ], "services-arr-options-shb.arr.jackett.settings.ProxyType": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.ProxyType" ], "services-arr-options-shb.arr.jackett.settings.ProxyUrl": [ "services-arr.html#services-arr-options-shb.arr.jackett.settings.ProxyUrl" ], "services-arr-options-shb.arr.jackett.ssl": [ "services-arr.html#services-arr-options-shb.arr.jackett.ssl" ], "services-arr-options-shb.arr.jackett.ssl.paths": [ "services-arr.html#services-arr-options-shb.arr.jackett.ssl.paths" ], "services-arr-options-shb.arr.jackett.ssl.paths.cert": [ "services-arr.html#services-arr-options-shb.arr.jackett.ssl.paths.cert" ], "services-arr-options-shb.arr.jackett.ssl.paths.key": [ "services-arr.html#services-arr-options-shb.arr.jackett.ssl.paths.key" ], "services-arr-options-shb.arr.jackett.ssl.systemdService": [ "services-arr.html#services-arr-options-shb.arr.jackett.ssl.systemdService" ], "services-arr-options-shb.arr.jackett.subdomain": [ "services-arr.html#services-arr-options-shb.arr.jackett.subdomain" ], "services-arr-options-shb.arr.lidarr": [ "services-arr.html#services-arr-options-shb.arr.lidarr" ], "services-arr-options-shb.arr.lidarr.authEndpoint": [ "services-arr.html#services-arr-options-shb.arr.lidarr.authEndpoint" ], "services-arr-options-shb.arr.lidarr.backup": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup" ], "services-arr-options-shb.arr.lidarr.backup.request": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.request" ], "services-arr-options-shb.arr.lidarr.backup.request.excludePatterns": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.excludePatterns" ], "services-arr-options-shb.arr.lidarr.backup.request.hooks": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.hooks" ], "services-arr-options-shb.arr.lidarr.backup.request.hooks.afterBackup": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.hooks.afterBackup" ], "services-arr-options-shb.arr.lidarr.backup.request.hooks.beforeBackup": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.hooks.beforeBackup" ], "services-arr-options-shb.arr.lidarr.backup.request.sourceDirectories": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.sourceDirectories" ], "services-arr-options-shb.arr.lidarr.backup.request.user": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.request.user" ], "services-arr-options-shb.arr.lidarr.backup.result": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.result" ], "services-arr-options-shb.arr.lidarr.backup.result.backupService": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.result.backupService" ], "services-arr-options-shb.arr.lidarr.backup.result.restoreScript": [ "services-arr.html#services-arr-options-shb.arr.lidarr.backup.result.restoreScript" ], "services-arr-options-shb.arr.lidarr.dashboard": [ "services-arr.html#services-arr-options-shb.arr.lidarr.dashboard" ], "services-arr-options-shb.arr.lidarr.dashboard.request": [ "services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.request" ], "services-arr-options-shb.arr.lidarr.dashboard.request.externalUrl": [ "services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.request.externalUrl" ], "services-arr-options-shb.arr.lidarr.dashboard.request.internalUrl": [ "services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.request.internalUrl" ], "services-arr-options-shb.arr.lidarr.dashboard.result": [ "services-arr.html#services-arr-options-shb.arr.lidarr.dashboard.result" ], "services-arr-options-shb.arr.lidarr.dataDir": [ "services-arr.html#services-arr-options-shb.arr.lidarr.dataDir" ], "services-arr-options-shb.arr.lidarr.domain": [ "services-arr.html#services-arr-options-shb.arr.lidarr.domain" ], "services-arr-options-shb.arr.lidarr.enable": [ "services-arr.html#services-arr-options-shb.arr.lidarr.enable" ], "services-arr-options-shb.arr.lidarr.ldapUserGroup": [ "services-arr.html#services-arr-options-shb.arr.lidarr.ldapUserGroup" ], "services-arr-options-shb.arr.lidarr.settings": [ "services-arr.html#services-arr-options-shb.arr.lidarr.settings" ], "services-arr-options-shb.arr.lidarr.settings.ApiKey": [ "services-arr.html#services-arr-options-shb.arr.lidarr.settings.ApiKey" ], "services-arr-options-shb.arr.lidarr.settings.ApiKey.source": [ "services-arr.html#services-arr-options-shb.arr.lidarr.settings.ApiKey.source" ], "services-arr-options-shb.arr.lidarr.settings.ApiKey.transform": [ "services-arr.html#services-arr-options-shb.arr.lidarr.settings.ApiKey.transform" ], "services-arr-options-shb.arr.lidarr.settings.LogLevel": [ "services-arr.html#services-arr-options-shb.arr.lidarr.settings.LogLevel" ], "services-arr-options-shb.arr.lidarr.settings.Port": [ "services-arr.html#services-arr-options-shb.arr.lidarr.settings.Port" ], "services-arr-options-shb.arr.lidarr.ssl": [ "services-arr.html#services-arr-options-shb.arr.lidarr.ssl" ], "services-arr-options-shb.arr.lidarr.ssl.paths": [ "services-arr.html#services-arr-options-shb.arr.lidarr.ssl.paths" ], "services-arr-options-shb.arr.lidarr.ssl.paths.cert": [ "services-arr.html#services-arr-options-shb.arr.lidarr.ssl.paths.cert" ], "services-arr-options-shb.arr.lidarr.ssl.paths.key": [ "services-arr.html#services-arr-options-shb.arr.lidarr.ssl.paths.key" ], "services-arr-options-shb.arr.lidarr.ssl.systemdService": [ "services-arr.html#services-arr-options-shb.arr.lidarr.ssl.systemdService" ], "services-arr-options-shb.arr.lidarr.subdomain": [ "services-arr.html#services-arr-options-shb.arr.lidarr.subdomain" ], "services-arr-options-shb.arr.radarr": [ "services-arr.html#services-arr-options-shb.arr.radarr" ], "services-arr-options-shb.arr.radarr.authEndpoint": [ "services-arr.html#services-arr-options-shb.arr.radarr.authEndpoint" ], "services-arr-options-shb.arr.radarr.backup": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup" ], "services-arr-options-shb.arr.radarr.backup.request": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.request" ], "services-arr-options-shb.arr.radarr.backup.request.excludePatterns": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.request.excludePatterns" ], "services-arr-options-shb.arr.radarr.backup.request.hooks": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.request.hooks" ], "services-arr-options-shb.arr.radarr.backup.request.hooks.afterBackup": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.request.hooks.afterBackup" ], "services-arr-options-shb.arr.radarr.backup.request.hooks.beforeBackup": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.request.hooks.beforeBackup" ], "services-arr-options-shb.arr.radarr.backup.request.sourceDirectories": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.request.sourceDirectories" ], "services-arr-options-shb.arr.radarr.backup.request.user": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.request.user" ], "services-arr-options-shb.arr.radarr.backup.result": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.result" ], "services-arr-options-shb.arr.radarr.backup.result.backupService": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.result.backupService" ], "services-arr-options-shb.arr.radarr.backup.result.restoreScript": [ "services-arr.html#services-arr-options-shb.arr.radarr.backup.result.restoreScript" ], "services-arr-options-shb.arr.radarr.dashboard": [ "services-arr.html#services-arr-options-shb.arr.radarr.dashboard" ], "services-arr-options-shb.arr.radarr.dashboard.request": [ "services-arr.html#services-arr-options-shb.arr.radarr.dashboard.request" ], "services-arr-options-shb.arr.radarr.dashboard.request.externalUrl": [ "services-arr.html#services-arr-options-shb.arr.radarr.dashboard.request.externalUrl" ], "services-arr-options-shb.arr.radarr.dashboard.request.internalUrl": [ "services-arr.html#services-arr-options-shb.arr.radarr.dashboard.request.internalUrl" ], "services-arr-options-shb.arr.radarr.dashboard.result": [ "services-arr.html#services-arr-options-shb.arr.radarr.dashboard.result" ], "services-arr-options-shb.arr.radarr.dataDir": [ "services-arr.html#services-arr-options-shb.arr.radarr.dataDir" ], "services-arr-options-shb.arr.radarr.domain": [ "services-arr.html#services-arr-options-shb.arr.radarr.domain" ], "services-arr-options-shb.arr.radarr.enable": [ "services-arr.html#services-arr-options-shb.arr.radarr.enable" ], "services-arr-options-shb.arr.radarr.ldapUserGroup": [ "services-arr.html#services-arr-options-shb.arr.radarr.ldapUserGroup" ], "services-arr-options-shb.arr.radarr.settings": [ "services-arr.html#services-arr-options-shb.arr.radarr.settings" ], "services-arr-options-shb.arr.radarr.settings.AnalyticsEnabled": [ "services-arr.html#services-arr-options-shb.arr.radarr.settings.AnalyticsEnabled" ], "services-arr-options-shb.arr.radarr.settings.ApiKey": [ "services-arr.html#services-arr-options-shb.arr.radarr.settings.ApiKey" ], "services-arr-options-shb.arr.radarr.settings.ApiKey.source": [ "services-arr.html#services-arr-options-shb.arr.radarr.settings.ApiKey.source" ], "services-arr-options-shb.arr.radarr.settings.ApiKey.transform": [ "services-arr.html#services-arr-options-shb.arr.radarr.settings.ApiKey.transform" ], "services-arr-options-shb.arr.radarr.settings.LogLevel": [ "services-arr.html#services-arr-options-shb.arr.radarr.settings.LogLevel" ], "services-arr-options-shb.arr.radarr.settings.Port": [ "services-arr.html#services-arr-options-shb.arr.radarr.settings.Port" ], "services-arr-options-shb.arr.radarr.ssl": [ "services-arr.html#services-arr-options-shb.arr.radarr.ssl" ], "services-arr-options-shb.arr.radarr.ssl.paths": [ "services-arr.html#services-arr-options-shb.arr.radarr.ssl.paths" ], "services-arr-options-shb.arr.radarr.ssl.paths.cert": [ "services-arr.html#services-arr-options-shb.arr.radarr.ssl.paths.cert" ], "services-arr-options-shb.arr.radarr.ssl.paths.key": [ "services-arr.html#services-arr-options-shb.arr.radarr.ssl.paths.key" ], "services-arr-options-shb.arr.radarr.ssl.systemdService": [ "services-arr.html#services-arr-options-shb.arr.radarr.ssl.systemdService" ], "services-arr-options-shb.arr.radarr.subdomain": [ "services-arr.html#services-arr-options-shb.arr.radarr.subdomain" ], "services-arr-options-shb.arr.readarr": [ "services-arr.html#services-arr-options-shb.arr.readarr" ], "services-arr-options-shb.arr.readarr.authEndpoint": [ "services-arr.html#services-arr-options-shb.arr.readarr.authEndpoint" ], "services-arr-options-shb.arr.readarr.backup": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup" ], "services-arr-options-shb.arr.readarr.backup.request": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.request" ], "services-arr-options-shb.arr.readarr.backup.request.excludePatterns": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.request.excludePatterns" ], "services-arr-options-shb.arr.readarr.backup.request.hooks": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.request.hooks" ], "services-arr-options-shb.arr.readarr.backup.request.hooks.afterBackup": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.request.hooks.afterBackup" ], "services-arr-options-shb.arr.readarr.backup.request.hooks.beforeBackup": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.request.hooks.beforeBackup" ], "services-arr-options-shb.arr.readarr.backup.request.sourceDirectories": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.request.sourceDirectories" ], "services-arr-options-shb.arr.readarr.backup.request.user": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.request.user" ], "services-arr-options-shb.arr.readarr.backup.result": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.result" ], "services-arr-options-shb.arr.readarr.backup.result.backupService": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.result.backupService" ], "services-arr-options-shb.arr.readarr.backup.result.restoreScript": [ "services-arr.html#services-arr-options-shb.arr.readarr.backup.result.restoreScript" ], "services-arr-options-shb.arr.readarr.dashboard": [ "services-arr.html#services-arr-options-shb.arr.readarr.dashboard" ], "services-arr-options-shb.arr.readarr.dashboard.request": [ "services-arr.html#services-arr-options-shb.arr.readarr.dashboard.request" ], "services-arr-options-shb.arr.readarr.dashboard.request.externalUrl": [ "services-arr.html#services-arr-options-shb.arr.readarr.dashboard.request.externalUrl" ], "services-arr-options-shb.arr.readarr.dashboard.request.internalUrl": [ "services-arr.html#services-arr-options-shb.arr.readarr.dashboard.request.internalUrl" ], "services-arr-options-shb.arr.readarr.dashboard.result": [ "services-arr.html#services-arr-options-shb.arr.readarr.dashboard.result" ], "services-arr-options-shb.arr.readarr.dataDir": [ "services-arr.html#services-arr-options-shb.arr.readarr.dataDir" ], "services-arr-options-shb.arr.readarr.domain": [ "services-arr.html#services-arr-options-shb.arr.readarr.domain" ], "services-arr-options-shb.arr.readarr.enable": [ "services-arr.html#services-arr-options-shb.arr.readarr.enable" ], "services-arr-options-shb.arr.readarr.ldapUserGroup": [ "services-arr.html#services-arr-options-shb.arr.readarr.ldapUserGroup" ], "services-arr-options-shb.arr.readarr.settings": [ "services-arr.html#services-arr-options-shb.arr.readarr.settings" ], "services-arr-options-shb.arr.readarr.settings.ApiKey": [ "services-arr.html#services-arr-options-shb.arr.readarr.settings.ApiKey" ], "services-arr-options-shb.arr.readarr.settings.ApiKey.source": [ "services-arr.html#services-arr-options-shb.arr.readarr.settings.ApiKey.source" ], "services-arr-options-shb.arr.readarr.settings.ApiKey.transform": [ "services-arr.html#services-arr-options-shb.arr.readarr.settings.ApiKey.transform" ], "services-arr-options-shb.arr.readarr.settings.LogLevel": [ "services-arr.html#services-arr-options-shb.arr.readarr.settings.LogLevel" ], "services-arr-options-shb.arr.readarr.settings.Port": [ "services-arr.html#services-arr-options-shb.arr.readarr.settings.Port" ], "services-arr-options-shb.arr.readarr.ssl": [ "services-arr.html#services-arr-options-shb.arr.readarr.ssl" ], "services-arr-options-shb.arr.readarr.ssl.paths": [ "services-arr.html#services-arr-options-shb.arr.readarr.ssl.paths" ], "services-arr-options-shb.arr.readarr.ssl.paths.cert": [ "services-arr.html#services-arr-options-shb.arr.readarr.ssl.paths.cert" ], "services-arr-options-shb.arr.readarr.ssl.paths.key": [ "services-arr.html#services-arr-options-shb.arr.readarr.ssl.paths.key" ], "services-arr-options-shb.arr.readarr.ssl.systemdService": [ "services-arr.html#services-arr-options-shb.arr.readarr.ssl.systemdService" ], "services-arr-options-shb.arr.readarr.subdomain": [ "services-arr.html#services-arr-options-shb.arr.readarr.subdomain" ], "services-arr-options-shb.arr.sonarr": [ "services-arr.html#services-arr-options-shb.arr.sonarr" ], "services-arr-options-shb.arr.sonarr.authEndpoint": [ "services-arr.html#services-arr-options-shb.arr.sonarr.authEndpoint" ], "services-arr-options-shb.arr.sonarr.backup": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup" ], "services-arr-options-shb.arr.sonarr.backup.request": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.request" ], "services-arr-options-shb.arr.sonarr.backup.request.excludePatterns": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.excludePatterns" ], "services-arr-options-shb.arr.sonarr.backup.request.hooks": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.hooks" ], "services-arr-options-shb.arr.sonarr.backup.request.hooks.afterBackup": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.hooks.afterBackup" ], "services-arr-options-shb.arr.sonarr.backup.request.hooks.beforeBackup": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.hooks.beforeBackup" ], "services-arr-options-shb.arr.sonarr.backup.request.sourceDirectories": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.sourceDirectories" ], "services-arr-options-shb.arr.sonarr.backup.request.user": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.request.user" ], "services-arr-options-shb.arr.sonarr.backup.result": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.result" ], "services-arr-options-shb.arr.sonarr.backup.result.backupService": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.result.backupService" ], "services-arr-options-shb.arr.sonarr.backup.result.restoreScript": [ "services-arr.html#services-arr-options-shb.arr.sonarr.backup.result.restoreScript" ], "services-arr-options-shb.arr.sonarr.dashboard": [ "services-arr.html#services-arr-options-shb.arr.sonarr.dashboard" ], "services-arr-options-shb.arr.sonarr.dashboard.request": [ "services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.request" ], "services-arr-options-shb.arr.sonarr.dashboard.request.externalUrl": [ "services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.request.externalUrl" ], "services-arr-options-shb.arr.sonarr.dashboard.request.internalUrl": [ "services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.request.internalUrl" ], "services-arr-options-shb.arr.sonarr.dashboard.result": [ "services-arr.html#services-arr-options-shb.arr.sonarr.dashboard.result" ], "services-arr-options-shb.arr.sonarr.dataDir": [ "services-arr.html#services-arr-options-shb.arr.sonarr.dataDir" ], "services-arr-options-shb.arr.sonarr.domain": [ "services-arr.html#services-arr-options-shb.arr.sonarr.domain" ], "services-arr-options-shb.arr.sonarr.enable": [ "services-arr.html#services-arr-options-shb.arr.sonarr.enable" ], "services-arr-options-shb.arr.sonarr.ldapUserGroup": [ "services-arr.html#services-arr-options-shb.arr.sonarr.ldapUserGroup" ], "services-arr-options-shb.arr.sonarr.settings": [ "services-arr.html#services-arr-options-shb.arr.sonarr.settings" ], "services-arr-options-shb.arr.sonarr.settings.ApiKey": [ "services-arr.html#services-arr-options-shb.arr.sonarr.settings.ApiKey" ], "services-arr-options-shb.arr.sonarr.settings.ApiKey.source": [ "services-arr.html#services-arr-options-shb.arr.sonarr.settings.ApiKey.source" ], "services-arr-options-shb.arr.sonarr.settings.ApiKey.transform": [ "services-arr.html#services-arr-options-shb.arr.sonarr.settings.ApiKey.transform" ], "services-arr-options-shb.arr.sonarr.settings.LogLevel": [ "services-arr.html#services-arr-options-shb.arr.sonarr.settings.LogLevel" ], "services-arr-options-shb.arr.sonarr.settings.Port": [ "services-arr.html#services-arr-options-shb.arr.sonarr.settings.Port" ], "services-arr-options-shb.arr.sonarr.ssl": [ "services-arr.html#services-arr-options-shb.arr.sonarr.ssl" ], "services-arr-options-shb.arr.sonarr.ssl.paths": [ "services-arr.html#services-arr-options-shb.arr.sonarr.ssl.paths" ], "services-arr-options-shb.arr.sonarr.ssl.paths.cert": [ "services-arr.html#services-arr-options-shb.arr.sonarr.ssl.paths.cert" ], "services-arr-options-shb.arr.sonarr.ssl.paths.key": [ "services-arr.html#services-arr-options-shb.arr.sonarr.ssl.paths.key" ], "services-arr-options-shb.arr.sonarr.ssl.systemdService": [ "services-arr.html#services-arr-options-shb.arr.sonarr.ssl.systemdService" ], "services-arr-options-shb.arr.sonarr.subdomain": [ "services-arr.html#services-arr-options-shb.arr.sonarr.subdomain" ], "services-arr-usage": [ "services-arr.html#services-arr-usage" ], "services-arr-usage-apikeys": [ "services-arr.html#services-arr-usage-apikeys" ], "services-arr-usage-applicationdashboard": [ "services-arr.html#services-arr-usage-applicationdashboard" ], "services-arr-usage-configuration": [ "services-arr.html#services-arr-usage-configuration" ], "services-arr-usage-jackett-proxy": [ "services-arr.html#services-arr-usage-jackett-proxy" ], "services-authelia-features": [ "blocks-authelia.html#services-authelia-features" ], "services-authelia-usage": [ "blocks-authelia.html#services-authelia-usage" ], "services-authelia-usage-applicationdashboard": [ "blocks-authelia.html#services-authelia-usage-applicationdashboard" ], "services-category-ai": [ "services.html#services-category-ai" ], "services-category-automation": [ "services.html#services-category-automation" ], "services-category-code": [ "services.html#services-category-code" ], "services-category-dashboard": [ "services.html#services-category-dashboard" ], "services-category-documents": [ "services.html#services-category-documents" ], "services-category-emails": [ "services.html#services-category-emails" ], "services-category-finance": [ "services.html#services-category-finance" ], "services-category-media": [ "services.html#services-category-media" ], "services-category-passwords": [ "services.html#services-category-passwords" ], "services-firefly-iii": [ "services-firefly-iii.html#services-firefly-iii" ], "services-firefly-iii-certs": [ "services-firefly-iii.html#services-firefly-iii-certs" ], "services-firefly-iii-database-inspection": [ "services-firefly-iii.html#services-firefly-iii-database-inspection" ], "services-firefly-iii-declarative-ldap": [ "services-firefly-iii.html#services-firefly-iii-declarative-ldap" ], "services-firefly-iii-features": [ "services-firefly-iii.html#services-firefly-iii-features" ], "services-firefly-iii-impermanence": [ "services-firefly-iii.html#services-firefly-iii-impermanence" ], "services-firefly-iii-mobile": [ "services-firefly-iii.html#services-firefly-iii-mobile" ], "services-firefly-iii-options": [ "services-firefly-iii.html#services-firefly-iii-options" ], "services-firefly-iii-options-shb.firefly-iii.appKey": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey" ], "services-firefly-iii-options-shb.firefly-iii.appKey.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request" ], "services-firefly-iii-options-shb.firefly-iii.appKey.request.group": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.group" ], "services-firefly-iii-options-shb.firefly-iii.appKey.request.mode": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.mode" ], "services-firefly-iii-options-shb.firefly-iii.appKey.request.owner": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.owner" ], "services-firefly-iii-options-shb.firefly-iii.appKey.request.restartUnits": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.request.restartUnits" ], "services-firefly-iii-options-shb.firefly-iii.appKey.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.result" ], "services-firefly-iii-options-shb.firefly-iii.appKey.result.path": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.appKey.result.path" ], "services-firefly-iii-options-shb.firefly-iii.backup": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup" ], "services-firefly-iii-options-shb.firefly-iii.backup.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request" ], "services-firefly-iii-options-shb.firefly-iii.backup.request.excludePatterns": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.excludePatterns" ], "services-firefly-iii-options-shb.firefly-iii.backup.request.hooks": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.hooks" ], "services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.afterBackup": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.afterBackup" ], "services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.beforeBackup": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.hooks.beforeBackup" ], "services-firefly-iii-options-shb.firefly-iii.backup.request.sourceDirectories": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.sourceDirectories" ], "services-firefly-iii-options-shb.firefly-iii.backup.request.user": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.request.user" ], "services-firefly-iii-options-shb.firefly-iii.backup.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.result" ], "services-firefly-iii-options-shb.firefly-iii.backup.result.backupService": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.result.backupService" ], "services-firefly-iii-options-shb.firefly-iii.backup.result.restoreScript": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.backup.result.restoreScript" ], "services-firefly-iii-options-shb.firefly-iii.dashboard": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard" ], "services-firefly-iii-options-shb.firefly-iii.dashboard.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.request" ], "services-firefly-iii-options-shb.firefly-iii.dashboard.request.externalUrl": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.request.externalUrl" ], "services-firefly-iii-options-shb.firefly-iii.dashboard.request.internalUrl": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.request.internalUrl" ], "services-firefly-iii-options-shb.firefly-iii.dashboard.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dashboard.result" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword.request.group": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.group" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword.request.mode": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.mode" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword.request.owner": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.owner" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword.request.restartUnits": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.request.restartUnits" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.result" ], "services-firefly-iii-options-shb.firefly-iii.dbPassword.result.path": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.dbPassword.result.path" ], "services-firefly-iii-options-shb.firefly-iii.debug": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.debug" ], "services-firefly-iii-options-shb.firefly-iii.domain": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.domain" ], "services-firefly-iii-options-shb.firefly-iii.enable": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.enable" ], "services-firefly-iii-options-shb.firefly-iii.impermanence": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.impermanence" ], "services-firefly-iii-options-shb.firefly-iii.importer": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer" ], "services-firefly-iii-options-shb.firefly-iii.importer.enable": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.enable" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.group": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.group" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.mode": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.mode" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.owner": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.owner" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.restartUnits": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.request.restartUnits" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result" ], "services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result.path": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.firefly-iii-accessToken.result.path" ], "services-firefly-iii-options-shb.firefly-iii.importer.subdomain": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.importer.subdomain" ], "services-firefly-iii-options-shb.firefly-iii.ldap": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ldap" ], "services-firefly-iii-options-shb.firefly-iii.ldap.adminGroup": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ldap.adminGroup" ], "services-firefly-iii-options-shb.firefly-iii.ldap.userGroup": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ldap.userGroup" ], "services-firefly-iii-options-shb.firefly-iii.siteOwnerEmail": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.siteOwnerEmail" ], "services-firefly-iii-options-shb.firefly-iii.smtp": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp" ], "services-firefly-iii-options-shb.firefly-iii.smtp.from_address": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.from_address" ], "services-firefly-iii-options-shb.firefly-iii.smtp.host": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.host" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password.request.group": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.group" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password.request.mode": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.mode" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password.request.owner": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.owner" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password.request.restartUnits": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.request.restartUnits" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.result" ], "services-firefly-iii-options-shb.firefly-iii.smtp.password.result.path": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.password.result.path" ], "services-firefly-iii-options-shb.firefly-iii.smtp.port": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.port" ], "services-firefly-iii-options-shb.firefly-iii.smtp.username": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.smtp.username" ], "services-firefly-iii-options-shb.firefly-iii.ssl": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl" ], "services-firefly-iii-options-shb.firefly-iii.ssl.paths": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.paths" ], "services-firefly-iii-options-shb.firefly-iii.ssl.paths.cert": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.paths.cert" ], "services-firefly-iii-options-shb.firefly-iii.ssl.paths.key": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.paths.key" ], "services-firefly-iii-options-shb.firefly-iii.ssl.systemdService": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.ssl.systemdService" ], "services-firefly-iii-options-shb.firefly-iii.sso": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso" ], "services-firefly-iii-options-shb.firefly-iii.sso.adminGroup": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.adminGroup" ], "services-firefly-iii-options-shb.firefly-iii.sso.authEndpoint": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.authEndpoint" ], "services-firefly-iii-options-shb.firefly-iii.sso.authorization_policy": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.authorization_policy" ], "services-firefly-iii-options-shb.firefly-iii.sso.clientID": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.clientID" ], "services-firefly-iii-options-shb.firefly-iii.sso.enable": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.enable" ], "services-firefly-iii-options-shb.firefly-iii.sso.port": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.port" ], "services-firefly-iii-options-shb.firefly-iii.sso.provider": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.provider" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret.request.group": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.group" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret.request.mode": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.mode" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret.request.owner": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.owner" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret.request.restartUnits": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.request.restartUnits" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.result" ], "services-firefly-iii-options-shb.firefly-iii.sso.secret.result.path": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secret.result.path" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.group": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.group" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.mode": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.mode" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.owner": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.owner" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.restartUnits": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.request.restartUnits" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result" ], "services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result.path": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.sso.secretForAuthelia.result.path" ], "services-firefly-iii-options-shb.firefly-iii.subdomain": [ "services-firefly-iii.html#services-firefly-iii-options-shb.firefly-iii.subdomain" ], "services-firefly-iii-usage": [ "services-firefly-iii.html#services-firefly-iii-usage" ], "services-firefly-iii-usage-applicationdashboard": [ "services-firefly-iii.html#services-firefly-iii-usage-applicationdashboard" ], "services-firefly-iii-usage-backup": [ "services-firefly-iii.html#services-firefly-iii-usage-backup" ], "services-firefly-iii-usage-configuration": [ "services-firefly-iii.html#services-firefly-iii-usage-configuration" ], "services-forgejo": [ "services-forgejo.html#services-forgejo" ], "services-forgejo-debug": [ "services-forgejo.html#services-forgejo-debug" ], "services-forgejo-features": [ "services-forgejo.html#services-forgejo-features" ], "services-forgejo-options": [ "services-forgejo.html#services-forgejo-options" ], "services-forgejo-options-shb.forgejo.backup": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup" ], "services-forgejo-options-shb.forgejo.backup.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request" ], "services-forgejo-options-shb.forgejo.backup.request.excludePatterns": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.excludePatterns" ], "services-forgejo-options-shb.forgejo.backup.request.hooks": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.hooks" ], "services-forgejo-options-shb.forgejo.backup.request.hooks.afterBackup": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.hooks.afterBackup" ], "services-forgejo-options-shb.forgejo.backup.request.hooks.beforeBackup": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.hooks.beforeBackup" ], "services-forgejo-options-shb.forgejo.backup.request.sourceDirectories": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.sourceDirectories" ], "services-forgejo-options-shb.forgejo.backup.request.user": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.request.user" ], "services-forgejo-options-shb.forgejo.backup.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.result" ], "services-forgejo-options-shb.forgejo.backup.result.backupService": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.result.backupService" ], "services-forgejo-options-shb.forgejo.backup.result.restoreScript": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.backup.result.restoreScript" ], "services-forgejo-options-shb.forgejo.dashboard": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard" ], "services-forgejo-options-shb.forgejo.dashboard.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.request" ], "services-forgejo-options-shb.forgejo.dashboard.request.externalUrl": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.request.externalUrl" ], "services-forgejo-options-shb.forgejo.dashboard.request.internalUrl": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.request.internalUrl" ], "services-forgejo-options-shb.forgejo.dashboard.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.dashboard.result" ], "services-forgejo-options-shb.forgejo.databasePassword": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword" ], "services-forgejo-options-shb.forgejo.databasePassword.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request" ], "services-forgejo-options-shb.forgejo.databasePassword.request.group": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.group" ], "services-forgejo-options-shb.forgejo.databasePassword.request.mode": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.mode" ], "services-forgejo-options-shb.forgejo.databasePassword.request.owner": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.owner" ], "services-forgejo-options-shb.forgejo.databasePassword.request.restartUnits": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.request.restartUnits" ], "services-forgejo-options-shb.forgejo.databasePassword.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.result" ], "services-forgejo-options-shb.forgejo.databasePassword.result.path": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.databasePassword.result.path" ], "services-forgejo-options-shb.forgejo.debug": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.debug" ], "services-forgejo-options-shb.forgejo.domain": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.domain" ], "services-forgejo-options-shb.forgejo.enable": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.enable" ], "services-forgejo-options-shb.forgejo.hostPackages": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.hostPackages" ], "services-forgejo-options-shb.forgejo.ldap": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap" ], "services-forgejo-options-shb.forgejo.ldap.adminGroup": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminGroup" ], "services-forgejo-options-shb.forgejo.ldap.adminName": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminName" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword.request.group": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.group" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword.request.mode": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.mode" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword.request.owner": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.owner" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword.request.restartUnits": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.request.restartUnits" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.result" ], "services-forgejo-options-shb.forgejo.ldap.adminPassword.result.path": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.adminPassword.result.path" ], "services-forgejo-options-shb.forgejo.ldap.dcdomain": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.dcdomain" ], "services-forgejo-options-shb.forgejo.ldap.enable": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.enable" ], "services-forgejo-options-shb.forgejo.ldap.host": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.host" ], "services-forgejo-options-shb.forgejo.ldap.port": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.port" ], "services-forgejo-options-shb.forgejo.ldap.provider": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.provider" ], "services-forgejo-options-shb.forgejo.ldap.userGroup": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.userGroup" ], "services-forgejo-options-shb.forgejo.ldap.waitForSystemdServices": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ldap.waitForSystemdServices" ], "services-forgejo-options-shb.forgejo.localActionRunner": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.localActionRunner" ], "services-forgejo-options-shb.forgejo.mount": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.mount" ], "services-forgejo-options-shb.forgejo.mount.path": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.mount.path" ], "services-forgejo-options-shb.forgejo.repositoryRoot": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.repositoryRoot" ], "services-forgejo-options-shb.forgejo.smtp": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp" ], "services-forgejo-options-shb.forgejo.smtp.from_address": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.from_address" ], "services-forgejo-options-shb.forgejo.smtp.host": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.host" ], "services-forgejo-options-shb.forgejo.smtp.password": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password" ], "services-forgejo-options-shb.forgejo.smtp.password.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request" ], "services-forgejo-options-shb.forgejo.smtp.password.request.group": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.group" ], "services-forgejo-options-shb.forgejo.smtp.password.request.mode": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.mode" ], "services-forgejo-options-shb.forgejo.smtp.password.request.owner": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.owner" ], "services-forgejo-options-shb.forgejo.smtp.password.request.restartUnits": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.request.restartUnits" ], "services-forgejo-options-shb.forgejo.smtp.password.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.result" ], "services-forgejo-options-shb.forgejo.smtp.password.result.path": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.password.result.path" ], "services-forgejo-options-shb.forgejo.smtp.port": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.port" ], "services-forgejo-options-shb.forgejo.smtp.username": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.smtp.username" ], "services-forgejo-options-shb.forgejo.ssl": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ssl" ], "services-forgejo-options-shb.forgejo.ssl.paths": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.paths" ], "services-forgejo-options-shb.forgejo.ssl.paths.cert": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.paths.cert" ], "services-forgejo-options-shb.forgejo.ssl.paths.key": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.paths.key" ], "services-forgejo-options-shb.forgejo.ssl.systemdService": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.ssl.systemdService" ], "services-forgejo-options-shb.forgejo.sso": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso" ], "services-forgejo-options-shb.forgejo.sso.authorization_policy": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.authorization_policy" ], "services-forgejo-options-shb.forgejo.sso.clientID": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.clientID" ], "services-forgejo-options-shb.forgejo.sso.enable": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.enable" ], "services-forgejo-options-shb.forgejo.sso.endpoint": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.endpoint" ], "services-forgejo-options-shb.forgejo.sso.provider": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.provider" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret.request.group": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.group" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret.request.mode": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.mode" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret.request.owner": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.owner" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret.request.restartUnits": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.request.restartUnits" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.result" ], "services-forgejo-options-shb.forgejo.sso.sharedSecret.result.path": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecret.result.path" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.group": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.group" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.mode": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.mode" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.owner": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.owner" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.restartUnits": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.request.restartUnits" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result" ], "services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result.path": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.sso.sharedSecretForAuthelia.result.path" ], "services-forgejo-options-shb.forgejo.subdomain": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.subdomain" ], "services-forgejo-options-shb.forgejo.users": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users" ], "services-forgejo-options-shb.forgejo.users._name_.email": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.email" ], "services-forgejo-options-shb.forgejo.users._name_.isAdmin": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.isAdmin" ], "services-forgejo-options-shb.forgejo.users._name_.password": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password" ], "services-forgejo-options-shb.forgejo.users._name_.password.request": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request" ], "services-forgejo-options-shb.forgejo.users._name_.password.request.group": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.group" ], "services-forgejo-options-shb.forgejo.users._name_.password.request.mode": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.mode" ], "services-forgejo-options-shb.forgejo.users._name_.password.request.owner": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.owner" ], "services-forgejo-options-shb.forgejo.users._name_.password.request.restartUnits": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.request.restartUnits" ], "services-forgejo-options-shb.forgejo.users._name_.password.result": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.result" ], "services-forgejo-options-shb.forgejo.users._name_.password.result.path": [ "services-forgejo.html#services-forgejo-options-shb.forgejo.users._name_.password.result.path" ], "services-forgejo-usage": [ "services-forgejo.html#services-forgejo-usage" ], "services-forgejo-usage-applicationdashboard": [ "services-forgejo.html#services-forgejo-usage-applicationdashboard" ], "services-forgejo-usage-backup": [ "services-forgejo.html#services-forgejo-usage-backup" ], "services-forgejo-usage-configuration": [ "services-forgejo.html#services-forgejo-usage-configuration" ], "services-forgejo-usage-extra-settings": [ "services-forgejo.html#services-forgejo-usage-extra-settings" ], "services-forgejo-usage-https": [ "services-forgejo.html#services-forgejo-usage-https" ], "services-forgejo-usage-ldap": [ "services-forgejo.html#services-forgejo-usage-ldap" ], "services-forgejo-usage-smtp": [ "services-forgejo.html#services-forgejo-usage-smtp" ], "services-forgejo-usage-sso": [ "services-forgejo.html#services-forgejo-usage-sso" ], "services-home-assistant": [ "services-home-assistant.html#services-home-assistant" ], "services-home-assistant-debug": [ "services-home-assistant.html#services-home-assistant-debug" ], "services-home-assistant-features": [ "services-home-assistant.html#services-home-assistant-features" ], "services-home-assistant-options": [ "services-home-assistant.html#services-home-assistant-options" ], "services-home-assistant-options-shb.home-assistant.backup": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup" ], "services-home-assistant-options-shb.home-assistant.backup.request": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request" ], "services-home-assistant-options-shb.home-assistant.backup.request.excludePatterns": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.excludePatterns" ], "services-home-assistant-options-shb.home-assistant.backup.request.hooks": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.hooks" ], "services-home-assistant-options-shb.home-assistant.backup.request.hooks.afterBackup": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.hooks.afterBackup" ], "services-home-assistant-options-shb.home-assistant.backup.request.hooks.beforeBackup": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.hooks.beforeBackup" ], "services-home-assistant-options-shb.home-assistant.backup.request.sourceDirectories": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.sourceDirectories" ], "services-home-assistant-options-shb.home-assistant.backup.request.user": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.request.user" ], "services-home-assistant-options-shb.home-assistant.backup.result": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.result" ], "services-home-assistant-options-shb.home-assistant.backup.result.backupService": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.result.backupService" ], "services-home-assistant-options-shb.home-assistant.backup.result.restoreScript": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.backup.result.restoreScript" ], "services-home-assistant-options-shb.home-assistant.config": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config" ], "services-home-assistant-options-shb.home-assistant.config.country": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.country" ], "services-home-assistant-options-shb.home-assistant.config.latitude": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.latitude" ], "services-home-assistant-options-shb.home-assistant.config.longitude": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.longitude" ], "services-home-assistant-options-shb.home-assistant.config.name": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.name" ], "services-home-assistant-options-shb.home-assistant.config.time_zone": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.time_zone" ], "services-home-assistant-options-shb.home-assistant.config.unit_system": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.config.unit_system" ], "services-home-assistant-options-shb.home-assistant.dashboard": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard" ], "services-home-assistant-options-shb.home-assistant.dashboard.request": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.request" ], "services-home-assistant-options-shb.home-assistant.dashboard.request.externalUrl": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.request.externalUrl" ], "services-home-assistant-options-shb.home-assistant.dashboard.request.internalUrl": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.request.internalUrl" ], "services-home-assistant-options-shb.home-assistant.dashboard.result": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.dashboard.result" ], "services-home-assistant-options-shb.home-assistant.domain": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.domain" ], "services-home-assistant-options-shb.home-assistant.enable": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.enable" ], "services-home-assistant-options-shb.home-assistant.ldap": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap" ], "services-home-assistant-options-shb.home-assistant.ldap.enable": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.enable" ], "services-home-assistant-options-shb.home-assistant.ldap.host": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.host" ], "services-home-assistant-options-shb.home-assistant.ldap.keepDefaultAuth": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.keepDefaultAuth" ], "services-home-assistant-options-shb.home-assistant.ldap.port": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.port" ], "services-home-assistant-options-shb.home-assistant.ldap.userGroup": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ldap.userGroup" ], "services-home-assistant-options-shb.home-assistant.ssl": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl" ], "services-home-assistant-options-shb.home-assistant.ssl.paths": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.paths" ], "services-home-assistant-options-shb.home-assistant.ssl.paths.cert": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.paths.cert" ], "services-home-assistant-options-shb.home-assistant.ssl.paths.key": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.paths.key" ], "services-home-assistant-options-shb.home-assistant.ssl.systemdService": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.ssl.systemdService" ], "services-home-assistant-options-shb.home-assistant.subdomain": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.subdomain" ], "services-home-assistant-options-shb.home-assistant.voice": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice" ], "services-home-assistant-options-shb.home-assistant.voice.speech-to-text": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice.speech-to-text" ], "services-home-assistant-options-shb.home-assistant.voice.text-to-speech": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice.text-to-speech" ], "services-home-assistant-options-shb.home-assistant.voice.wakeword": [ "services-home-assistant.html#services-home-assistant-options-shb.home-assistant.voice.wakeword" ], "services-home-assistant-usage": [ "services-home-assistant.html#services-home-assistant-usage" ], "services-home-assistant-usage-applicationdashboard": [ "services-home-assistant.html#services-home-assistant-usage-applicationdashboard" ], "services-home-assistant-usage-backup": [ "services-home-assistant.html#services-home-assistant-usage-backup" ], "services-home-assistant-usage-configuration": [ "services-home-assistant.html#services-home-assistant-usage-configuration" ], "services-home-assistant-usage-custom-components": [ "services-home-assistant.html#services-home-assistant-usage-custom-components" ], "services-home-assistant-usage-custom-lovelace-modules": [ "services-home-assistant.html#services-home-assistant-usage-custom-lovelace-modules" ], "services-home-assistant-usage-extra-components": [ "services-home-assistant.html#services-home-assistant-usage-extra-components" ], "services-home-assistant-usage-extra-groups": [ "services-home-assistant.html#services-home-assistant-usage-extra-groups" ], "services-home-assistant-usage-extra-packages": [ "services-home-assistant.html#services-home-assistant-usage-extra-packages" ], "services-home-assistant-usage-https": [ "services-home-assistant.html#services-home-assistant-usage-https" ], "services-home-assistant-usage-ldap": [ "services-home-assistant.html#services-home-assistant-usage-ldap" ], "services-home-assistant-usage-music-assistant": [ "services-home-assistant.html#services-home-assistant-usage-music-assistant" ], "services-home-assistant-usage-sso": [ "services-home-assistant.html#services-home-assistant-usage-sso" ], "services-home-assistant-usage-voice": [ "services-home-assistant.html#services-home-assistant-usage-voice" ], "services-homepage": [ "services-homepage.html#services-homepage" ], "services-homepage-features": [ "services-homepage.html#services-homepage-features" ], "services-homepage-options": [ "services-homepage.html#services-homepage-options" ], "services-homepage-options-shb.homepage.domain": [ "services-homepage.html#services-homepage-options-shb.homepage.domain" ], "services-homepage-options-shb.homepage.enable": [ "services-homepage.html#services-homepage-options-shb.homepage.enable" ], "services-homepage-options-shb.homepage.ldap": [ "services-homepage.html#services-homepage-options-shb.homepage.ldap" ], "services-homepage-options-shb.homepage.ldap.userGroup": [ "services-homepage.html#services-homepage-options-shb.homepage.ldap.userGroup" ], "services-homepage-options-shb.homepage.servicesGroups": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups" ], "services-homepage-options-shb.homepage.servicesGroups._name_.name": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.name" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.group": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.group" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.mode": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.mode" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.owner": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.owner" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.restartUnits": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.request.restartUnits" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result.path": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.apiKey.result.path" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.externalUrl": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.externalUrl" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.internalUrl": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.request.internalUrl" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.result": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard.result" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.name": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.name" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.settings": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.settings" ], "services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.sortOrder": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.sortOrder" ], "services-homepage-options-shb.homepage.servicesGroups._name_.sortOrder": [ "services-homepage.html#services-homepage-options-shb.homepage.servicesGroups._name_.sortOrder" ], "services-homepage-options-shb.homepage.ssl": [ "services-homepage.html#services-homepage-options-shb.homepage.ssl" ], "services-homepage-options-shb.homepage.ssl.paths": [ "services-homepage.html#services-homepage-options-shb.homepage.ssl.paths" ], "services-homepage-options-shb.homepage.ssl.paths.cert": [ "services-homepage.html#services-homepage-options-shb.homepage.ssl.paths.cert" ], "services-homepage-options-shb.homepage.ssl.paths.key": [ "services-homepage.html#services-homepage-options-shb.homepage.ssl.paths.key" ], "services-homepage-options-shb.homepage.ssl.systemdService": [ "services-homepage.html#services-homepage-options-shb.homepage.ssl.systemdService" ], "services-homepage-options-shb.homepage.sso": [ "services-homepage.html#services-homepage-options-shb.homepage.sso" ], "services-homepage-options-shb.homepage.sso.authEndpoint": [ "services-homepage.html#services-homepage-options-shb.homepage.sso.authEndpoint" ], "services-homepage-options-shb.homepage.sso.authorization_policy": [ "services-homepage.html#services-homepage-options-shb.homepage.sso.authorization_policy" ], "services-homepage-options-shb.homepage.sso.enable": [ "services-homepage.html#services-homepage-options-shb.homepage.sso.enable" ], "services-homepage-options-shb.homepage.subdomain": [ "services-homepage.html#services-homepage-options-shb.homepage.subdomain" ], "services-homepage-usage": [ "services-homepage.html#services-homepage-usage" ], "services-homepage-usage-custom": [ "services-homepage.html#services-homepage-usage-custom" ], "services-homepage-usage-main": [ "services-homepage.html#services-homepage-usage-main" ], "services-homepage-usage-service": [ "services-homepage.html#services-homepage-usage-service" ], "services-homepage-usage-widget": [ "services-homepage.html#services-homepage-usage-widget" ], "services-jellyfin": [ "services-jellyfin.html#services-jellyfin" ], "services-jellyfin-certs": [ "services-jellyfin.html#services-jellyfin-certs" ], "services-jellyfin-debug": [ "services-jellyfin.html#services-jellyfin-debug" ], "services-jellyfin-declarative-ldap": [ "services-jellyfin.html#services-jellyfin-declarative-ldap" ], "services-jellyfin-features": [ "services-jellyfin.html#services-jellyfin-features" ], "services-jellyfin-impermanence": [ "services-jellyfin.html#services-jellyfin-impermanence" ], "services-jellyfin-options": [ "services-jellyfin.html#services-jellyfin-options" ], "services-jellyfin-options-shb.jellyfin.admin": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin" ], "services-jellyfin-options-shb.jellyfin.admin.password": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password" ], "services-jellyfin-options-shb.jellyfin.admin.password.request": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request" ], "services-jellyfin-options-shb.jellyfin.admin.password.request.group": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.group" ], "services-jellyfin-options-shb.jellyfin.admin.password.request.mode": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.mode" ], "services-jellyfin-options-shb.jellyfin.admin.password.request.owner": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.owner" ], "services-jellyfin-options-shb.jellyfin.admin.password.request.restartUnits": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.request.restartUnits" ], "services-jellyfin-options-shb.jellyfin.admin.password.result": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.result" ], "services-jellyfin-options-shb.jellyfin.admin.password.result.path": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.password.result.path" ], "services-jellyfin-options-shb.jellyfin.admin.username": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.admin.username" ], "services-jellyfin-options-shb.jellyfin.backup": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup" ], "services-jellyfin-options-shb.jellyfin.backup.request": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request" ], "services-jellyfin-options-shb.jellyfin.backup.request.excludePatterns": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.excludePatterns" ], "services-jellyfin-options-shb.jellyfin.backup.request.hooks": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.hooks" ], "services-jellyfin-options-shb.jellyfin.backup.request.hooks.afterBackup": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.hooks.afterBackup" ], "services-jellyfin-options-shb.jellyfin.backup.request.hooks.beforeBackup": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.hooks.beforeBackup" ], "services-jellyfin-options-shb.jellyfin.backup.request.sourceDirectories": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.sourceDirectories" ], "services-jellyfin-options-shb.jellyfin.backup.request.user": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.request.user" ], "services-jellyfin-options-shb.jellyfin.backup.result": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.result" ], "services-jellyfin-options-shb.jellyfin.backup.result.backupService": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.result.backupService" ], "services-jellyfin-options-shb.jellyfin.backup.result.restoreScript": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.backup.result.restoreScript" ], "services-jellyfin-options-shb.jellyfin.dashboard": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard" ], "services-jellyfin-options-shb.jellyfin.dashboard.request": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.request" ], "services-jellyfin-options-shb.jellyfin.dashboard.request.externalUrl": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.request.externalUrl" ], "services-jellyfin-options-shb.jellyfin.dashboard.request.internalUrl": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.request.internalUrl" ], "services-jellyfin-options-shb.jellyfin.dashboard.result": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.dashboard.result" ], "services-jellyfin-options-shb.jellyfin.debug": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.debug" ], "services-jellyfin-options-shb.jellyfin.domain": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.domain" ], "services-jellyfin-options-shb.jellyfin.enable": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.enable" ], "services-jellyfin-options-shb.jellyfin.ldap": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap" ], "services-jellyfin-options-shb.jellyfin.ldap.adminGroup": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminGroup" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.group": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.group" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.mode": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.mode" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.owner": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.owner" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.restartUnits": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.request.restartUnits" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result" ], "services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result.path": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.adminPassword.result.path" ], "services-jellyfin-options-shb.jellyfin.ldap.dcdomain": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.dcdomain" ], "services-jellyfin-options-shb.jellyfin.ldap.enable": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.enable" ], "services-jellyfin-options-shb.jellyfin.ldap.host": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.host" ], "services-jellyfin-options-shb.jellyfin.ldap.plugin": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.plugin" ], "services-jellyfin-options-shb.jellyfin.ldap.port": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.port" ], "services-jellyfin-options-shb.jellyfin.ldap.userGroup": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ldap.userGroup" ], "services-jellyfin-options-shb.jellyfin.plugins": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.plugins" ], "services-jellyfin-options-shb.jellyfin.port": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.port" ], "services-jellyfin-options-shb.jellyfin.ssl": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl" ], "services-jellyfin-options-shb.jellyfin.ssl.paths": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.paths" ], "services-jellyfin-options-shb.jellyfin.ssl.paths.cert": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.paths.cert" ], "services-jellyfin-options-shb.jellyfin.ssl.paths.key": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.paths.key" ], "services-jellyfin-options-shb.jellyfin.ssl.systemdService": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.ssl.systemdService" ], "services-jellyfin-options-shb.jellyfin.sso": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso" ], "services-jellyfin-options-shb.jellyfin.sso.authorization_policy": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.authorization_policy" ], "services-jellyfin-options-shb.jellyfin.sso.clientID": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.clientID" ], "services-jellyfin-options-shb.jellyfin.sso.enable": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.enable" ], "services-jellyfin-options-shb.jellyfin.sso.endpoint": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.endpoint" ], "services-jellyfin-options-shb.jellyfin.sso.plugin": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.plugin" ], "services-jellyfin-options-shb.jellyfin.sso.provider": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.provider" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.group": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.group" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.mode": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.mode" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.owner": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.owner" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.restartUnits": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.request.restartUnits" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result.path": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecret.result.path" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.group": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.group" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.mode": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.mode" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.owner": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.owner" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.restartUnits": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.request.restartUnits" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result" ], "services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result.path": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.sso.sharedSecretForAuthelia.result.path" ], "services-jellyfin-options-shb.jellyfin.subdomain": [ "services-jellyfin.html#services-jellyfin-options-shb.jellyfin.subdomain" ], "services-jellyfin-usage": [ "services-jellyfin.html#services-jellyfin-usage" ], "services-jellyfin-usage-applicationdashboard": [ "services-jellyfin.html#services-jellyfin-usage-applicationdashboard" ], "services-jellyfin-usage-backup": [ "services-jellyfin.html#services-jellyfin-usage-backup" ], "services-jellyfin-usage-configuration": [ "services-jellyfin.html#services-jellyfin-usage-configuration" ], "services-karakeep": [ "services-karakeep.html#services-karakeep" ], "services-karakeep-features": [ "services-karakeep.html#services-karakeep-features" ], "services-karakeep-ollama": [ "services-karakeep.html#services-karakeep-ollama" ], "services-karakeep-options": [ "services-karakeep.html#services-karakeep-options" ], "services-karakeep-options-shb.karakeep.backup": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup" ], "services-karakeep-options-shb.karakeep.backup.request": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request" ], "services-karakeep-options-shb.karakeep.backup.request.excludePatterns": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.excludePatterns" ], "services-karakeep-options-shb.karakeep.backup.request.hooks": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.hooks" ], "services-karakeep-options-shb.karakeep.backup.request.hooks.afterBackup": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.hooks.afterBackup" ], "services-karakeep-options-shb.karakeep.backup.request.hooks.beforeBackup": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.hooks.beforeBackup" ], "services-karakeep-options-shb.karakeep.backup.request.sourceDirectories": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.sourceDirectories" ], "services-karakeep-options-shb.karakeep.backup.request.user": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.request.user" ], "services-karakeep-options-shb.karakeep.backup.result": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.result" ], "services-karakeep-options-shb.karakeep.backup.result.backupService": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.result.backupService" ], "services-karakeep-options-shb.karakeep.backup.result.restoreScript": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.backup.result.restoreScript" ], "services-karakeep-options-shb.karakeep.dashboard": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard" ], "services-karakeep-options-shb.karakeep.dashboard.request": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.request" ], "services-karakeep-options-shb.karakeep.dashboard.request.externalUrl": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.request.externalUrl" ], "services-karakeep-options-shb.karakeep.dashboard.request.internalUrl": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.request.internalUrl" ], "services-karakeep-options-shb.karakeep.dashboard.result": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.dashboard.result" ], "services-karakeep-options-shb.karakeep.domain": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.domain" ], "services-karakeep-options-shb.karakeep.enable": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.enable" ], "services-karakeep-options-shb.karakeep.environment": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.environment" ], "services-karakeep-options-shb.karakeep.ldap": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.ldap" ], "services-karakeep-options-shb.karakeep.ldap.userGroup": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.ldap.userGroup" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey.request": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.group": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.group" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.mode": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.mode" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.owner": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.owner" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.restartUnits": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.request.restartUnits" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey.result": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.result" ], "services-karakeep-options-shb.karakeep.meilisearchMasterKey.result.path": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.meilisearchMasterKey.result.path" ], "services-karakeep-options-shb.karakeep.nextauthSecret": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret" ], "services-karakeep-options-shb.karakeep.nextauthSecret.request": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request" ], "services-karakeep-options-shb.karakeep.nextauthSecret.request.group": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.group" ], "services-karakeep-options-shb.karakeep.nextauthSecret.request.mode": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.mode" ], "services-karakeep-options-shb.karakeep.nextauthSecret.request.owner": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.owner" ], "services-karakeep-options-shb.karakeep.nextauthSecret.request.restartUnits": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.request.restartUnits" ], "services-karakeep-options-shb.karakeep.nextauthSecret.result": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.result" ], "services-karakeep-options-shb.karakeep.nextauthSecret.result.path": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.nextauthSecret.result.path" ], "services-karakeep-options-shb.karakeep.port": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.port" ], "services-karakeep-options-shb.karakeep.ssl": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.ssl" ], "services-karakeep-options-shb.karakeep.ssl.paths": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.paths" ], "services-karakeep-options-shb.karakeep.ssl.paths.cert": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.paths.cert" ], "services-karakeep-options-shb.karakeep.ssl.paths.key": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.paths.key" ], "services-karakeep-options-shb.karakeep.ssl.systemdService": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.ssl.systemdService" ], "services-karakeep-options-shb.karakeep.sso": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso" ], "services-karakeep-options-shb.karakeep.sso.authEndpoint": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.authEndpoint" ], "services-karakeep-options-shb.karakeep.sso.authorization_policy": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.authorization_policy" ], "services-karakeep-options-shb.karakeep.sso.clientID": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.clientID" ], "services-karakeep-options-shb.karakeep.sso.enable": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.enable" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret.request": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret.request.group": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.group" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret.request.mode": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.mode" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret.request.owner": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.owner" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret.request.restartUnits": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.request.restartUnits" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret.result": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.result" ], "services-karakeep-options-shb.karakeep.sso.sharedSecret.result.path": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecret.result.path" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.group": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.group" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.mode": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.mode" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.owner": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.owner" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.restartUnits": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.request.restartUnits" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result" ], "services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result.path": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.sso.sharedSecretForAuthelia.result.path" ], "services-karakeep-options-shb.karakeep.subdomain": [ "services-karakeep.html#services-karakeep-options-shb.karakeep.subdomain" ], "services-karakeep-usage": [ "services-karakeep.html#services-karakeep-usage" ], "services-karakeep-usage-applicationdashboard": [ "services-karakeep.html#services-karakeep-usage-applicationdashboard" ], "services-karakeep-usage-configuration": [ "services-karakeep.html#services-karakeep-usage-configuration" ], "services-mailserver": [ "services-mailserver.html#services-mailserver" ], "services-mailserver-applicationdashboard": [ "services-mailserver.html#services-mailserver-applicationdashboard" ], "services-mailserver-certs": [ "services-mailserver.html#services-mailserver-certs" ], "services-mailserver-debug": [ "services-mailserver.html#services-mailserver-debug" ], "services-mailserver-debug-auth": [ "services-mailserver.html#services-mailserver-debug-auth" ], "services-mailserver-debug-folder-mapping": [ "services-mailserver.html#services-mailserver-debug-folder-mapping" ], "services-mailserver-debug-folders": [ "services-mailserver.html#services-mailserver-debug-folders" ], "services-mailserver-debug-local-folders": [ "services-mailserver.html#services-mailserver-debug-local-folders" ], "services-mailserver-debug-ports": [ "services-mailserver.html#services-mailserver-debug-ports" ], "services-mailserver-debug-systemd": [ "services-mailserver.html#services-mailserver-debug-systemd" ], "services-mailserver-declarative-ldap": [ "services-mailserver.html#services-mailserver-declarative-ldap" ], "services-mailserver-impermanence": [ "services-mailserver.html#services-mailserver-impermanence" ], "services-mailserver-mobile": [ "services-mailserver.html#services-mailserver-mobile" ], "services-mailserver-options": [ "services-mailserver.html#services-mailserver-options" ], "services-mailserver-options-shb.mailserver.adminPassword": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword" ], "services-mailserver-options-shb.mailserver.adminPassword.request": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request" ], "services-mailserver-options-shb.mailserver.adminPassword.request.group": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.group" ], "services-mailserver-options-shb.mailserver.adminPassword.request.mode": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.mode" ], "services-mailserver-options-shb.mailserver.adminPassword.request.owner": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.owner" ], "services-mailserver-options-shb.mailserver.adminPassword.request.restartUnits": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.request.restartUnits" ], "services-mailserver-options-shb.mailserver.adminPassword.result": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.result" ], "services-mailserver-options-shb.mailserver.adminPassword.result.path": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminPassword.result.path" ], "services-mailserver-options-shb.mailserver.adminUsername": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.adminUsername" ], "services-mailserver-options-shb.mailserver.backup": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup" ], "services-mailserver-options-shb.mailserver.backup.request": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request" ], "services-mailserver-options-shb.mailserver.backup.request.excludePatterns": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.excludePatterns" ], "services-mailserver-options-shb.mailserver.backup.request.hooks": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.hooks" ], "services-mailserver-options-shb.mailserver.backup.request.hooks.afterBackup": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.hooks.afterBackup" ], "services-mailserver-options-shb.mailserver.backup.request.hooks.beforeBackup": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.hooks.beforeBackup" ], "services-mailserver-options-shb.mailserver.backup.request.sourceDirectories": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.sourceDirectories" ], "services-mailserver-options-shb.mailserver.backup.request.user": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.request.user" ], "services-mailserver-options-shb.mailserver.backup.result": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.result" ], "services-mailserver-options-shb.mailserver.backup.result.backupService": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.result.backupService" ], "services-mailserver-options-shb.mailserver.backup.result.restoreScript": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backup.result.restoreScript" ], "services-mailserver-options-shb.mailserver.backupDKIM": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM" ], "services-mailserver-options-shb.mailserver.backupDKIM.request": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request" ], "services-mailserver-options-shb.mailserver.backupDKIM.request.excludePatterns": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.excludePatterns" ], "services-mailserver-options-shb.mailserver.backupDKIM.request.hooks": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.hooks" ], "services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.afterBackup": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.afterBackup" ], "services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.beforeBackup": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.hooks.beforeBackup" ], "services-mailserver-options-shb.mailserver.backupDKIM.request.sourceDirectories": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.sourceDirectories" ], "services-mailserver-options-shb.mailserver.backupDKIM.request.user": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.request.user" ], "services-mailserver-options-shb.mailserver.backupDKIM.result": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.result" ], "services-mailserver-options-shb.mailserver.backupDKIM.result.backupService": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.result.backupService" ], "services-mailserver-options-shb.mailserver.backupDKIM.result.restoreScript": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.backupDKIM.result.restoreScript" ], "services-mailserver-options-shb.mailserver.dashboard": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard" ], "services-mailserver-options-shb.mailserver.dashboard.request": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.request" ], "services-mailserver-options-shb.mailserver.dashboard.request.externalUrl": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.request.externalUrl" ], "services-mailserver-options-shb.mailserver.dashboard.request.internalUrl": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.request.internalUrl" ], "services-mailserver-options-shb.mailserver.dashboard.result": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.dashboard.result" ], "services-mailserver-options-shb.mailserver.domain": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.domain" ], "services-mailserver-options-shb.mailserver.enable": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.enable" ], "services-mailserver-options-shb.mailserver.imapSync": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync" ], "services-mailserver-options-shb.mailserver.imapSync.accounts": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.host": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.host" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialDrafts": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialDrafts" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialJunk": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialJunk" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialSent": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialSent" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialTrash": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.mapSpecialTrash" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.group": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.group" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.mode": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.mode" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.owner": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.owner" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.restartUnits": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.request.restartUnits" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result.path": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.password.result.path" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.port": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.port" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.sslType": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.sslType" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.timeout": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.timeout" ], "services-mailserver-options-shb.mailserver.imapSync.accounts._name_.username": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.accounts._name_.username" ], "services-mailserver-options-shb.mailserver.imapSync.debug": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.debug" ], "services-mailserver-options-shb.mailserver.imapSync.syncTimer": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.imapSync.syncTimer" ], "services-mailserver-options-shb.mailserver.impermanence": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.impermanence" ], "services-mailserver-options-shb.mailserver.ldap": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap" ], "services-mailserver-options-shb.mailserver.ldap.account": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.account" ], "services-mailserver-options-shb.mailserver.ldap.adminName": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminName" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword.request": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword.request.group": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.group" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword.request.mode": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.mode" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword.request.owner": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.owner" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword.request.restartUnits": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.request.restartUnits" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword.result": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.result" ], "services-mailserver-options-shb.mailserver.ldap.adminPassword.result.path": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.adminPassword.result.path" ], "services-mailserver-options-shb.mailserver.ldap.dcdomain": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.dcdomain" ], "services-mailserver-options-shb.mailserver.ldap.enable": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.enable" ], "services-mailserver-options-shb.mailserver.ldap.host": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.host" ], "services-mailserver-options-shb.mailserver.ldap.port": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.port" ], "services-mailserver-options-shb.mailserver.ldap.userGroup": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ldap.userGroup" ], "services-mailserver-options-shb.mailserver.smtpRelay": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay" ], "services-mailserver-options-shb.mailserver.smtpRelay.host": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.host" ], "services-mailserver-options-shb.mailserver.smtpRelay.password": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password" ], "services-mailserver-options-shb.mailserver.smtpRelay.password.request": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request" ], "services-mailserver-options-shb.mailserver.smtpRelay.password.request.group": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.group" ], "services-mailserver-options-shb.mailserver.smtpRelay.password.request.mode": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.mode" ], "services-mailserver-options-shb.mailserver.smtpRelay.password.request.owner": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.owner" ], "services-mailserver-options-shb.mailserver.smtpRelay.password.request.restartUnits": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.request.restartUnits" ], "services-mailserver-options-shb.mailserver.smtpRelay.password.result": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.result" ], "services-mailserver-options-shb.mailserver.smtpRelay.password.result.path": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.password.result.path" ], "services-mailserver-options-shb.mailserver.smtpRelay.port": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.port" ], "services-mailserver-options-shb.mailserver.smtpRelay.username": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.smtpRelay.username" ], "services-mailserver-options-shb.mailserver.ssl": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ssl" ], "services-mailserver-options-shb.mailserver.ssl.paths": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.paths" ], "services-mailserver-options-shb.mailserver.ssl.paths.cert": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.paths.cert" ], "services-mailserver-options-shb.mailserver.ssl.paths.key": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.paths.key" ], "services-mailserver-options-shb.mailserver.ssl.systemdService": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.ssl.systemdService" ], "services-mailserver-options-shb.mailserver.subdomain": [ "services-mailserver.html#services-mailserver-options-shb.mailserver.subdomain" ], "services-mailserver-usage": [ "services-mailserver.html#services-mailserver-usage" ], "services-mailserver-usage-backup": [ "services-mailserver.html#services-mailserver-usage-backup" ], "services-mailserver-usage-disk-layout": [ "services-mailserver.html#services-mailserver-usage-disk-layout" ], "services-mailserver-usage-ldap": [ "services-mailserver.html#services-mailserver-usage-ldap" ], "services-mailserver-usage-secrets": [ "services-mailserver.html#services-mailserver-usage-secrets" ], "services-monitoring-features": [ "blocks-monitoring.html#services-monitoring-features" ], "services-nextcloudserver": [ "services-nextcloud.html#services-nextcloudserver" ], "services-nextcloudserver-dashboard": [ "services-nextcloud.html#services-nextcloudserver-dashboard" ], "services-nextcloudserver-debug": [ "services-nextcloud.html#services-nextcloudserver-debug" ], "services-nextcloudserver-demo": [ "services-nextcloud.html#services-nextcloudserver-demo" ], "services-nextcloudserver-features": [ "services-nextcloud.html#services-nextcloudserver-features" ], "services-nextcloudserver-options": [ "services-nextcloud.html#services-nextcloudserver-options" ], "services-nextcloudserver-options-shb.nextcloud.adminPass": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass" ], "services-nextcloudserver-options-shb.nextcloud.adminPass.request": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request" ], "services-nextcloudserver-options-shb.nextcloud.adminPass.request.group": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.group" ], "services-nextcloudserver-options-shb.nextcloud.adminPass.request.mode": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.mode" ], "services-nextcloudserver-options-shb.nextcloud.adminPass.request.owner": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.owner" ], "services-nextcloudserver-options-shb.nextcloud.adminPass.request.restartUnits": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.request.restartUnits" ], "services-nextcloudserver-options-shb.nextcloud.adminPass.result": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.result" ], "services-nextcloudserver-options-shb.nextcloud.adminPass.result.path": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.adminPass.result.path" ], "services-nextcloudserver-options-shb.nextcloud.alwaysApplyExpensiveMigrations": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.alwaysApplyExpensiveMigrations" ], "services-nextcloudserver-options-shb.nextcloud.apps": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps" ], "services-nextcloudserver-options-shb.nextcloud.apps.externalStorage": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage" ], "services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.enable" ], "services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount" ], "services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.directory": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.directory" ], "services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.mountName": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.externalStorage.userLocalMount.mountName" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminName": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminName" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.group": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.group" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.mode": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.mode" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.owner": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.owner" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.restartUnits": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.request.restartUnits" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result.path": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.adminPassword.result.path" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.configID": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.configID" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.dcdomain": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.dcdomain" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.enable" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.host": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.host" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.port": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.port" ], "services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup" ], "services-nextcloudserver-options-shb.nextcloud.apps.memories": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories" ], "services-nextcloudserver-options-shb.nextcloud.apps.memories.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories.enable" ], "services-nextcloudserver-options-shb.nextcloud.apps.memories.photosPath": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories.photosPath" ], "services-nextcloudserver-options-shb.nextcloud.apps.memories.vaapi": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.memories.vaapi" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.enable" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.jwtSecretFile": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.jwtSecretFile" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.localNetworkIPRange": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.localNetworkIPRange" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.cert": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.cert" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.key": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.paths.key" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.systemdService": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.ssl.systemdService" ], "services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.subdomain": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.onlyoffice.subdomain" ], "services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator" ], "services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.debug": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.debug" ], "services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.enable" ], "services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.recommendedSettings": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.previewgenerator.recommendedSettings" ], "services-nextcloudserver-options-shb.nextcloud.apps.recognize": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.recognize" ], "services-nextcloudserver-options-shb.nextcloud.apps.recognize.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.recognize.enable" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.authorization_policy": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.authorization_policy" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.clientID": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.clientID" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.enable" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.endpoint": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.endpoint" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.fallbackDefaultAuth": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.fallbackDefaultAuth" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.port": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.port" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.provider": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.provider" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.group": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.group" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.mode": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.mode" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.owner": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.owner" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.restartUnits": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.request.restartUnits" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result.path": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secret.result.path" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.group": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.group" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.mode": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.mode" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.owner": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.owner" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.restartUnits": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.request.restartUnits" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result" ], "services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result.path": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.apps.sso.secretForAuthelia.result.path" ], "services-nextcloudserver-options-shb.nextcloud.autoDisableMaintenanceModeOnStart": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.autoDisableMaintenanceModeOnStart" ], "services-nextcloudserver-options-shb.nextcloud.backup": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup" ], "services-nextcloudserver-options-shb.nextcloud.backup.request": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request" ], "services-nextcloudserver-options-shb.nextcloud.backup.request.excludePatterns": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.excludePatterns" ], "services-nextcloudserver-options-shb.nextcloud.backup.request.hooks": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.hooks" ], "services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.afterBackup": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.afterBackup" ], "services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.beforeBackup": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.hooks.beforeBackup" ], "services-nextcloudserver-options-shb.nextcloud.backup.request.sourceDirectories": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.sourceDirectories" ], "services-nextcloudserver-options-shb.nextcloud.backup.request.user": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.request.user" ], "services-nextcloudserver-options-shb.nextcloud.backup.result": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.result" ], "services-nextcloudserver-options-shb.nextcloud.backup.result.backupService": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.result.backupService" ], "services-nextcloudserver-options-shb.nextcloud.backup.result.restoreScript": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.backup.result.restoreScript" ], "services-nextcloudserver-options-shb.nextcloud.dashboard": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard" ], "services-nextcloudserver-options-shb.nextcloud.dashboard.request": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.request" ], "services-nextcloudserver-options-shb.nextcloud.dashboard.request.externalUrl": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.request.externalUrl" ], "services-nextcloudserver-options-shb.nextcloud.dashboard.request.internalUrl": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.request.internalUrl" ], "services-nextcloudserver-options-shb.nextcloud.dashboard.result": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dashboard.result" ], "services-nextcloudserver-options-shb.nextcloud.dataDir": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dataDir" ], "services-nextcloudserver-options-shb.nextcloud.debug": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.debug" ], "services-nextcloudserver-options-shb.nextcloud.defaultPhoneRegion": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.defaultPhoneRegion" ], "services-nextcloudserver-options-shb.nextcloud.domain": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.domain" ], "services-nextcloudserver-options-shb.nextcloud.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.enable" ], "services-nextcloudserver-options-shb.nextcloud.enableDashboard": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.enableDashboard" ], "services-nextcloudserver-options-shb.nextcloud.externalFqdn": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.externalFqdn" ], "services-nextcloudserver-options-shb.nextcloud.extraApps": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.extraApps" ], "services-nextcloudserver-options-shb.nextcloud.initialAdminUsername": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.initialAdminUsername" ], "services-nextcloudserver-options-shb.nextcloud.maxUploadSize": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.maxUploadSize" ], "services-nextcloudserver-options-shb.nextcloud.mountPointServices": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.mountPointServices" ], "services-nextcloudserver-options-shb.nextcloud.phpFpmPoolSettings": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPoolSettings" ], "services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter" ], "services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.enable": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.enable" ], "services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.port": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.phpFpmPrometheusExporter.port" ], "services-nextcloudserver-options-shb.nextcloud.port": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.port" ], "services-nextcloudserver-options-shb.nextcloud.postgresSettings": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.postgresSettings" ], "services-nextcloudserver-options-shb.nextcloud.ssl": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl" ], "services-nextcloudserver-options-shb.nextcloud.ssl.paths": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.paths" ], "services-nextcloudserver-options-shb.nextcloud.ssl.paths.cert": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.paths.cert" ], "services-nextcloudserver-options-shb.nextcloud.ssl.paths.key": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.paths.key" ], "services-nextcloudserver-options-shb.nextcloud.ssl.systemdService": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.ssl.systemdService" ], "services-nextcloudserver-options-shb.nextcloud.subdomain": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.subdomain" ], "services-nextcloudserver-options-shb.nextcloud.tracing": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.tracing" ], "services-nextcloudserver-options-shb.nextcloud.version": [ "services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.version" ], "services-nextcloudserver-server-usage-appdata": [ "services-nextcloud.html#services-nextcloudserver-server-usage-appdata" ], "services-nextcloudserver-server-usage-monitoring": [ "services-nextcloud.html#services-nextcloudserver-server-usage-monitoring" ], "services-nextcloudserver-server-usage-tracing": [ "services-nextcloud.html#services-nextcloudserver-server-usage-tracing" ], "services-nextcloudserver-usage": [ "services-nextcloud.html#services-nextcloudserver-usage" ], "services-nextcloudserver-usage-applicationdashboard": [ "services-nextcloud.html#services-nextcloudserver-usage-applicationdashboard" ], "services-nextcloudserver-usage-backup": [ "services-nextcloud.html#services-nextcloudserver-usage-backup" ], "services-nextcloudserver-usage-basic": [ "services-nextcloud.html#services-nextcloudserver-usage-basic" ], "services-nextcloudserver-usage-externalstorage": [ "services-nextcloud.html#services-nextcloudserver-usage-externalstorage" ], "services-nextcloudserver-usage-https": [ "services-nextcloud.html#services-nextcloudserver-usage-https" ], "services-nextcloudserver-usage-ldap": [ "services-nextcloud.html#services-nextcloudserver-usage-ldap" ], "services-nextcloudserver-usage-memories": [ "services-nextcloud.html#services-nextcloudserver-usage-memories" ], "services-nextcloudserver-usage-mount-point": [ "services-nextcloud.html#services-nextcloudserver-usage-mount-point" ], "services-nextcloudserver-usage-oidc": [ "services-nextcloud.html#services-nextcloudserver-usage-oidc" ], "services-nextcloudserver-usage-onlyoffice": [ "services-nextcloud.html#services-nextcloudserver-usage-onlyoffice" ], "services-nextcloudserver-usage-phpfpm": [ "services-nextcloud.html#services-nextcloudserver-usage-phpfpm" ], "services-nextcloudserver-usage-postgres": [ "services-nextcloud.html#services-nextcloudserver-usage-postgres" ], "services-nextcloudserver-usage-previewgenerator": [ "services-nextcloud.html#services-nextcloudserver-usage-previewgenerator" ], "services-nextcloudserver-usage-recognize": [ "services-nextcloud.html#services-nextcloudserver-usage-recognize" ], "services-nextcloudserver-usage-version": [ "services-nextcloud.html#services-nextcloudserver-usage-version" ], "services-open-webui": [ "services-open-webui.html#services-open-webui" ], "services-open-webui-features": [ "services-open-webui.html#services-open-webui-features" ], "services-open-webui-ollama": [ "services-open-webui.html#services-open-webui-ollama" ], "services-open-webui-options": [ "services-open-webui.html#services-open-webui-options" ], "services-open-webui-options-shb.open-webui.backup": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup" ], "services-open-webui-options-shb.open-webui.backup.request": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request" ], "services-open-webui-options-shb.open-webui.backup.request.excludePatterns": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.excludePatterns" ], "services-open-webui-options-shb.open-webui.backup.request.hooks": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.hooks" ], "services-open-webui-options-shb.open-webui.backup.request.hooks.afterBackup": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.hooks.afterBackup" ], "services-open-webui-options-shb.open-webui.backup.request.hooks.beforeBackup": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.hooks.beforeBackup" ], "services-open-webui-options-shb.open-webui.backup.request.sourceDirectories": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.sourceDirectories" ], "services-open-webui-options-shb.open-webui.backup.request.user": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.request.user" ], "services-open-webui-options-shb.open-webui.backup.result": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.result" ], "services-open-webui-options-shb.open-webui.backup.result.backupService": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.result.backupService" ], "services-open-webui-options-shb.open-webui.backup.result.restoreScript": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.backup.result.restoreScript" ], "services-open-webui-options-shb.open-webui.dashboard": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard" ], "services-open-webui-options-shb.open-webui.dashboard.request": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.request" ], "services-open-webui-options-shb.open-webui.dashboard.request.externalUrl": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.request.externalUrl" ], "services-open-webui-options-shb.open-webui.dashboard.request.internalUrl": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.request.internalUrl" ], "services-open-webui-options-shb.open-webui.dashboard.result": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.dashboard.result" ], "services-open-webui-options-shb.open-webui.domain": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.domain" ], "services-open-webui-options-shb.open-webui.enable": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.enable" ], "services-open-webui-options-shb.open-webui.environment": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.environment" ], "services-open-webui-options-shb.open-webui.ldap": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ldap" ], "services-open-webui-options-shb.open-webui.ldap.adminGroup": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ldap.adminGroup" ], "services-open-webui-options-shb.open-webui.ldap.userGroup": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ldap.userGroup" ], "services-open-webui-options-shb.open-webui.port": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.port" ], "services-open-webui-options-shb.open-webui.ssl": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ssl" ], "services-open-webui-options-shb.open-webui.ssl.paths": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.paths" ], "services-open-webui-options-shb.open-webui.ssl.paths.cert": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.paths.cert" ], "services-open-webui-options-shb.open-webui.ssl.paths.key": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.paths.key" ], "services-open-webui-options-shb.open-webui.ssl.systemdService": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.ssl.systemdService" ], "services-open-webui-options-shb.open-webui.sso": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso" ], "services-open-webui-options-shb.open-webui.sso.authEndpoint": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.authEndpoint" ], "services-open-webui-options-shb.open-webui.sso.authorization_policy": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.authorization_policy" ], "services-open-webui-options-shb.open-webui.sso.clientID": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.clientID" ], "services-open-webui-options-shb.open-webui.sso.enable": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.enable" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret.request": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret.request.group": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.group" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret.request.mode": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.mode" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret.request.owner": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.owner" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret.request.restartUnits": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.request.restartUnits" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret.result": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.result" ], "services-open-webui-options-shb.open-webui.sso.sharedSecret.result.path": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecret.result.path" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.group": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.group" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.mode": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.mode" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.owner": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.owner" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.restartUnits": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.request.restartUnits" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result" ], "services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result.path": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.sso.sharedSecretForAuthelia.result.path" ], "services-open-webui-options-shb.open-webui.subdomain": [ "services-open-webui.html#services-open-webui-options-shb.open-webui.subdomain" ], "services-open-webui-usage": [ "services-open-webui.html#services-open-webui-usage" ], "services-open-webui-usage-applicationdashboard": [ "services-open-webui.html#services-open-webui-usage-applicationdashboard" ], "services-open-webui-usage-backup": [ "services-open-webui.html#services-open-webui-usage-backup" ], "services-open-webui-usage-configuration": [ "services-open-webui.html#services-open-webui-usage-configuration" ], "services-pinchflat": [ "services-pinchflat.html#services-pinchflat" ], "services-pinchflat-features": [ "services-pinchflat.html#services-pinchflat-features" ], "services-pinchflat-options": [ "services-pinchflat.html#services-pinchflat-options" ], "services-pinchflat-options-shb.pinchflat.backup": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup" ], "services-pinchflat-options-shb.pinchflat.backup.request": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request" ], "services-pinchflat-options-shb.pinchflat.backup.request.excludePatterns": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.excludePatterns" ], "services-pinchflat-options-shb.pinchflat.backup.request.hooks": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.hooks" ], "services-pinchflat-options-shb.pinchflat.backup.request.hooks.afterBackup": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.hooks.afterBackup" ], "services-pinchflat-options-shb.pinchflat.backup.request.hooks.beforeBackup": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.hooks.beforeBackup" ], "services-pinchflat-options-shb.pinchflat.backup.request.sourceDirectories": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.sourceDirectories" ], "services-pinchflat-options-shb.pinchflat.backup.request.user": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.request.user" ], "services-pinchflat-options-shb.pinchflat.backup.result": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.result" ], "services-pinchflat-options-shb.pinchflat.backup.result.backupService": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.result.backupService" ], "services-pinchflat-options-shb.pinchflat.backup.result.restoreScript": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.backup.result.restoreScript" ], "services-pinchflat-options-shb.pinchflat.dashboard": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard" ], "services-pinchflat-options-shb.pinchflat.dashboard.request": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.request" ], "services-pinchflat-options-shb.pinchflat.dashboard.request.externalUrl": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.request.externalUrl" ], "services-pinchflat-options-shb.pinchflat.dashboard.request.internalUrl": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.request.internalUrl" ], "services-pinchflat-options-shb.pinchflat.dashboard.result": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.dashboard.result" ], "services-pinchflat-options-shb.pinchflat.domain": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.domain" ], "services-pinchflat-options-shb.pinchflat.enable": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.enable" ], "services-pinchflat-options-shb.pinchflat.ldap": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ldap" ], "services-pinchflat-options-shb.pinchflat.ldap.enable": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ldap.enable" ], "services-pinchflat-options-shb.pinchflat.ldap.userGroup": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ldap.userGroup" ], "services-pinchflat-options-shb.pinchflat.mediaDir": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.mediaDir" ], "services-pinchflat-options-shb.pinchflat.port": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.port" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase.request": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase.request.group": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.group" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase.request.mode": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.mode" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase.request.owner": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.owner" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase.request.restartUnits": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.request.restartUnits" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase.result": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.result" ], "services-pinchflat-options-shb.pinchflat.secretKeyBase.result.path": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.secretKeyBase.result.path" ], "services-pinchflat-options-shb.pinchflat.ssl": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl" ], "services-pinchflat-options-shb.pinchflat.ssl.paths": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.paths" ], "services-pinchflat-options-shb.pinchflat.ssl.paths.cert": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.paths.cert" ], "services-pinchflat-options-shb.pinchflat.ssl.paths.key": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.paths.key" ], "services-pinchflat-options-shb.pinchflat.ssl.systemdService": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.ssl.systemdService" ], "services-pinchflat-options-shb.pinchflat.sso": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso" ], "services-pinchflat-options-shb.pinchflat.sso.authEndpoint": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso.authEndpoint" ], "services-pinchflat-options-shb.pinchflat.sso.authorization_policy": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso.authorization_policy" ], "services-pinchflat-options-shb.pinchflat.sso.enable": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.sso.enable" ], "services-pinchflat-options-shb.pinchflat.subdomain": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.subdomain" ], "services-pinchflat-options-shb.pinchflat.timeZone": [ "services-pinchflat.html#services-pinchflat-options-shb.pinchflat.timeZone" ], "services-pinchflat-usage": [ "services-pinchflat.html#services-pinchflat-usage" ], "services-pinchflat-usage-applicationdashboard": [ "services-pinchflat.html#services-pinchflat-usage-applicationdashboard" ], "services-pinchflat-usage-backup": [ "services-pinchflat.html#services-pinchflat-usage-backup" ], "services-pinchflat-usage-configuration": [ "services-pinchflat.html#services-pinchflat-usage-configuration" ], "services-vaultwarden": [ "services-vaultwarden.html#services-vaultwarden" ], "services-vaultwarden-backup": [ "services-vaultwarden.html#services-vaultwarden-backup" ], "services-vaultwarden-debug": [ "services-vaultwarden.html#services-vaultwarden-debug" ], "services-vaultwarden-features": [ "services-vaultwarden.html#services-vaultwarden-features" ], "services-vaultwarden-maintenance": [ "services-vaultwarden.html#services-vaultwarden-maintenance" ], "services-vaultwarden-options": [ "services-vaultwarden.html#services-vaultwarden-options" ], "services-vaultwarden-options-shb.vaultwarden.authEndpoint": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.authEndpoint" ], "services-vaultwarden-options-shb.vaultwarden.backup": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup" ], "services-vaultwarden-options-shb.vaultwarden.backup.request": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request" ], "services-vaultwarden-options-shb.vaultwarden.backup.request.excludePatterns": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.excludePatterns" ], "services-vaultwarden-options-shb.vaultwarden.backup.request.hooks": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.hooks" ], "services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.afterBackup": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.afterBackup" ], "services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.beforeBackup": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.hooks.beforeBackup" ], "services-vaultwarden-options-shb.vaultwarden.backup.request.sourceDirectories": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.sourceDirectories" ], "services-vaultwarden-options-shb.vaultwarden.backup.request.user": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.request.user" ], "services-vaultwarden-options-shb.vaultwarden.backup.result": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.result" ], "services-vaultwarden-options-shb.vaultwarden.backup.result.backupService": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.result.backupService" ], "services-vaultwarden-options-shb.vaultwarden.backup.result.restoreScript": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.backup.result.restoreScript" ], "services-vaultwarden-options-shb.vaultwarden.dashboard": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard" ], "services-vaultwarden-options-shb.vaultwarden.dashboard.request": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.request" ], "services-vaultwarden-options-shb.vaultwarden.dashboard.request.externalUrl": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.request.externalUrl" ], "services-vaultwarden-options-shb.vaultwarden.dashboard.request.internalUrl": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.request.internalUrl" ], "services-vaultwarden-options-shb.vaultwarden.dashboard.result": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.dashboard.result" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword.request": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword.request.group": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.group" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword.request.mode": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.mode" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword.request.owner": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.owner" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword.request.restartUnits": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.request.restartUnits" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword.result": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.result" ], "services-vaultwarden-options-shb.vaultwarden.databasePassword.result.path": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.databasePassword.result.path" ], "services-vaultwarden-options-shb.vaultwarden.debug": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.debug" ], "services-vaultwarden-options-shb.vaultwarden.domain": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.domain" ], "services-vaultwarden-options-shb.vaultwarden.enable": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.enable" ], "services-vaultwarden-options-shb.vaultwarden.mount": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.mount" ], "services-vaultwarden-options-shb.vaultwarden.mount.path": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.mount.path" ], "services-vaultwarden-options-shb.vaultwarden.port": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.port" ], "services-vaultwarden-options-shb.vaultwarden.smtp": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp" ], "services-vaultwarden-options-shb.vaultwarden.smtp.auth_mechanism": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.auth_mechanism" ], "services-vaultwarden-options-shb.vaultwarden.smtp.from_address": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.from_address" ], "services-vaultwarden-options-shb.vaultwarden.smtp.from_name": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.from_name" ], "services-vaultwarden-options-shb.vaultwarden.smtp.host": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.host" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password.request": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password.request.group": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.group" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password.request.mode": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.mode" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password.request.owner": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.owner" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password.request.restartUnits": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.request.restartUnits" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password.result": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.result" ], "services-vaultwarden-options-shb.vaultwarden.smtp.password.result.path": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.password.result.path" ], "services-vaultwarden-options-shb.vaultwarden.smtp.port": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.port" ], "services-vaultwarden-options-shb.vaultwarden.smtp.security": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.security" ], "services-vaultwarden-options-shb.vaultwarden.smtp.username": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.smtp.username" ], "services-vaultwarden-options-shb.vaultwarden.ssl": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl" ], "services-vaultwarden-options-shb.vaultwarden.ssl.paths": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.paths" ], "services-vaultwarden-options-shb.vaultwarden.ssl.paths.cert": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.paths.cert" ], "services-vaultwarden-options-shb.vaultwarden.ssl.paths.key": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.paths.key" ], "services-vaultwarden-options-shb.vaultwarden.ssl.systemdService": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.ssl.systemdService" ], "services-vaultwarden-options-shb.vaultwarden.subdomain": [ "services-vaultwarden.html#services-vaultwarden-options-shb.vaultwarden.subdomain" ], "services-vaultwarden-usage": [ "services-vaultwarden.html#services-vaultwarden-usage" ], "services-vaultwarden-usage-applicationdashboard": [ "services-vaultwarden.html#services-vaultwarden-usage-applicationdashboard" ], "services-vaultwarden-usage-configuration": [ "services-vaultwarden.html#services-vaultwarden-usage-configuration" ], "services-vaultwarden-usage-https": [ "services-vaultwarden.html#services-vaultwarden-usage-https" ], "services-vaultwarden-usage-sso": [ "services-vaultwarden.html#services-vaultwarden-usage-sso" ], "services-vaultwarden-zfs": [ "services-vaultwarden.html#services-vaultwarden-zfs" ], "test-failures": [ "service-implementation-guide.html#test-failures" ], "testing-and-validation": [ "service-implementation-guide.html#testing-and-validation" ], "understand-target-service": [ "service-implementation-guide.html#understand-target-service" ], "update-flake-configuration": [ "service-implementation-guide.html#update-flake-configuration" ], "update-redirects-automatically": [ "service-implementation-guide.html#update-redirects-automatically" ], "usage": [ "usage.html#usage" ], "usage-examples": [ "usage.html#usage-examples" ], "usage-examples-colmena": [ "usage.html#usage-examples-colmena" ], "usage-examples-deploy-rs": [ "usage.html#usage-examples-deploy-rs" ], "usage-examples-nixosrebuild": [ "usage.html#usage-examples-nixosrebuild" ], "usage-flake": [ "usage.html#usage-flake" ], "usage-flake-autoupdate": [ "usage.html#usage-flake-autoupdate" ], "usage-flake-lib": [ "usage.html#usage-flake-lib" ], "usage-flake-modules": [ "usage.html#usage-flake-modules" ], "usage-flake-overlays": [ "usage.html#usage-flake-overlays" ], "usage-flake-patches": [ "usage.html#usage-flake-patches" ], "usage-flake-substituter": [ "usage.html#usage-flake-substituter" ], "usage-flake-tag": [ "usage.html#usage-flake-tag" ], "usage-flake-tag-explicit": [ "usage.html#usage-flake-tag-explicit" ], "usage-flake-tag-implicit": [ "usage.html#usage-flake-tag-implicit" ], "usage-flake-unfree": [ "usage.html#usage-flake-unfree" ], "usage-secrets": [ "usage.html#usage-secrets" ] } ================================================ FILE: docs/service-implementation-guide.md ================================================ # SelfHostBlocks Service Implementation Guide {#service-implementation-guide} This guide documents the complete process for implementing a new service in SelfHostBlocks, based on lessons learned from the nzbget implementation and analysis of existing service patterns. **Note**: SelfHostBlocks aims to be "the smallest amount of code above what is available in nixpkgs" (see `docs/contributing.md`). Services should leverage existing nixpkgs options when possible and focus on providing contract integrations rather than reimplementing configuration. ## What Makes a "Complete" SHB Service {#complete-shb-service} According to the project maintainer's criteria, a service is considered fully supported if it includes: 1. **SSL block integration** - HTTPS/TLS certificate management 2. **Backup block integration** - Automated backup of service data 3. **Monitoring integration** - Prometheus metrics and health checks 4. **LDAP (LLDAP) integration** - Directory-based authentication 5. **SSO (Authelia) integration** - Single sign-on authentication 6. **Comprehensive tests** - All integration variants tested ## Pre-Implementation Research {#pre-implementation-research} ### 1. Analyze Existing Services {#analyze-existing-services} Before starting, study existing services to understand patterns: ```bash # Study service patterns ls modules/services/ # List all services cat modules/services/deluge.nix # Best practice example cat modules/services/vaultwarden.nix # Another good example ``` **Key patterns to identify:** - Configuration structure and options - How contracts are used (SSL, backup, monitoring, secrets) - Authentication integration approaches - Service-specific settings and defaults ### 2. Understand the Target Service {#understand-target-service} Research the service you're implementing: - **Configuration format** (YAML, INI, JSON, etc.) - **Authentication methods** (built-in users, LDAP, OIDC/OAuth) - **API endpoints** (for monitoring/health checks) - **Data directories** (what needs backing up) - **Network requirements** (ports, protocols) - **Dependencies** (databases, external tools) ### 3. Check NixOS Integration {#check-nixos-integration} Verify nixpkgs support: ```bash # Check if NixOS service exists nix eval --impure --expr '(import { configuration = {...}: {}; }).options.services' --apply 'builtins.attrNames' --json | jq -r '.[]' | grep -i servicename # or search online: https://search.nixos.org/options?query=services.servicename ``` If no nixpkgs integration exists, you may need to: - Package the service first - Use containerized approach - Request upstream nixpkgs integration ## Implementation Steps {#implementation-steps} ### 1. Create the Service Module {#create-service-module} Location: `modules/services/servicename.nix` **Basic structure:** ```nix { config, pkgs, lib, ... }: let cfg = config.shb.servicename; contracts = pkgs.callPackage ../contracts {}; fqdn = "${cfg.subdomain}.${cfg.domain}"; # Choose appropriate format based on service config settingsFormat = pkgs.formats.yaml {}; # or .ini, .json, etc. in { options.shb.servicename = { # Core options (always required) enable = lib.mkEnableOption "selfhostblocks.servicename"; subdomain = lib.mkOption { ... }; domain = lib.mkOption { ... }; # SSL integration (always include) ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr contracts.ssl.certs; default = null; }; # Service-specific options port = lib.mkOption { ... }; dataDir = lib.mkOption { ... }; settings = lib.mkOption { type = lib.types.submodule { freeformType = settingsFormat.type; options = { # Define key options with descriptions }; }; }; # Authentication options authEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "OIDC endpoint for SSO"; default = null; }; ldap = lib.mkOption { ... }; # LDAP integration users = lib.mkOption { ... }; # Local user management # Integration options backup = lib.mkOption { type = lib.types.submodule { options = contracts.backup.mkRequester { user = "servicename"; sourceDirectories = [ cfg.dataDir ]; }; }; }; monitoring = lib.mkOption { type = lib.types.nullOr (lib.types.submodule { options = { # Service-specific monitoring options }; }); default = null; }; # System options extraServiceConfig = lib.mkOption { ... }; logLevel = lib.mkOption { ... }; }; config = lib.mkIf cfg.enable (lib.mkMerge [ { # Base service configuration services.servicename = { enable = true; # Map SHB options to nixpkgs service options }; # Nginx reverse proxy shb.nginx.vhosts = [{ inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString cfg.port}"; # SSO integration autheliaRules = lib.mkIf (cfg.authEndpoint != null) [ { domain = fqdn; policy = "bypass"; resources = [ "^/api" ]; # API endpoints } { domain = fqdn; policy = "two_factor"; resources = [ "^.*" ]; # Everything else } ]; }]; # User/group setup users.users.servicename = { extraGroups = [ "media" ]; # If needed for file access }; # Directory permissions systemd.tmpfiles.rules = [ "d ${cfg.dataDir} 0755 servicename servicename - -" ]; } # Monitoring configuration (conditional) (lib.mkIf (cfg.monitoring != null) { services.prometheus.scrapeConfigs = [{ job_name = "servicename"; static_configs = [{ targets = [ "127.0.0.1:${toString cfg.port}" ]; labels = { hostname = config.networking.hostName; domain = cfg.domain; }; }]; metrics_path = "/metrics"; # or appropriate endpoint scrape_interval = "30s"; }]; }) ]); } ``` ### 2. Key Implementation Considerations {#implementation-considerations} #### Configuration Management {#configuration-management} - **Use freeform settings** when possible: `freeformType = settingsFormat.type` - **Provide sensible defaults** for common options - **Use lib.mkDefault** for user-overridable settings - **Use lib.mkForce** for security-critical settings #### Authentication Integration {#authentication-integration} - **SSO (Authelia)**: Use `autheliaRules` with appropriate bypass policies - **LDAP**: Follow the patterns from existing services - **Local users**: Use SHB secret contracts for password management #### Security Best Practices {#security-best-practices} - **Bind to localhost**: Services should listen on `127.0.0.1` only - **Use nginx for TLS**: Don't configure TLS in the service itself - **Proper file permissions**: Use systemd.tmpfiles.rules - **Secret management**: Always use SHB secret contracts ### 3. Monitoring Implementation {#monitoring-implementation} Choose the appropriate monitoring approach: #### Option A: Native Prometheus Metrics {#native-prometheus-metrics} If the service supports Prometheus natively: ```nix services.prometheus.scrapeConfigs = [{ job_name = "servicename"; static_configs = [{ targets = [ "127.0.0.1:${toString cfg.port}" ]; }]; metrics_path = "/metrics"; }]; ``` #### Option B: API Health Check {#api-health-check} If no native metrics, monitor API endpoints: ```nix services.prometheus.scrapeConfigs = [{ job_name = "servicename"; static_configs = [{ targets = [ "127.0.0.1:${toString cfg.port}" ]; }]; metrics_path = "/api/status"; # or appropriate endpoint }]; ``` #### Option C: External Exporter {#external-exporter} For services requiring dedicated exporters (like Deluge): ```nix services.prometheus.exporters.servicename = { enable = true; # exporter-specific configuration }; ``` ### 4. Create Comprehensive Tests {#create-comprehensive-tests} Location: `test/services/servicename.nix` **Test structure:** ```nix { pkgs, ... }: let testLib = pkgs.callPackage ../common.nix {}; # Common test scripts commonTestScript = testLib.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.servicename.ssl); waitForServices = { ... }: [ "nginx.service" "servicename.service" ]; waitForPorts = { node, ... }: [ node.config.services.servicename.port ]; # Service-specific connectivity test extraScript = { node, proto_fqdn, ... }: '' with subtest("service connectivity"): response = curl(client, "", "${proto_fqdn}/api/health") # Add service-specific checks ''; }; # Monitoring test script prometheusTestScript = { nodes, ... }: '' server.wait_for_open_port(${toString nodes.server.config.services.servicename.port}) with subtest("prometheus monitoring"): # Test the actual monitoring endpoint response = server.succeed("curl -sSf http://localhost:${port}/metrics") # Validate response format ''; # Base configuration basic = { config, ... }: { imports = [ testLib.baseModule ../../modules/services/servicename.nix ]; shb.servicename = { enable = true; inherit (config.test) domain subdomain; # Basic configuration }; }; in { # Test variants (all 6 required) basic = lib.shb.test.runNixOSTest { ... }; backup = lib.shb.test.runNixOSTest { ... }; https = lib.shb.test.runNixOSTest { ... }; ldap = lib.shb.test.runNixOSTest { ... }; monitoring = lib.shb.test.runNixOSTest { ... }; sso = lib.shb.test.runNixOSTest { ... }; } ``` #### Required Test Variants {#required-test-variants} 1. **basic**: Core functionality without authentication 2. **backup**: Tests backup integration 3. **https**: Tests SSL/TLS integration 4. **ldap**: Tests LDAP authentication 5. **monitoring**: Tests Prometheus integration 6. **sso**: Tests Authelia SSO integration ### 5. Update Flake Configuration {#update-flake-configuration} Add to `flake.nix`: ```nix allModules = [ # ... existing modules modules/services/servicename.nix ]; ``` ```nix checks = { # ... existing checks // (vm_test "servicename" ./test/services/servicename.nix) }; ``` ### 6. Create Service Documentation {#create-service-documentation} Create comprehensive documentation for the new service: **Location**: `modules/services/servicename/docs/default.md` ```markdown # ServiceName Service {\#services-servicename} Brief description of what the service does. ## Features {\#services-servicename-features} - Feature 1 - Feature 2 ## Usage {\#services-servicename-usage} ### Basic Configuration {\#services-servicename-basic} shb.servicename = { enable = true; domain = "example.com"; subdomain = "servicename"; }; ### SSL Configuration {\#services-servicename-ssl} shb.servicename.ssl.paths = { cert = /path/to/cert; key = /path/to/key; }; ## Options Reference {\#services-servicename-options} {=include=} options id-prefix: services-servicename-options- list-id: selfhostblocks-servicename-options source: @OPTIONS_JSON@ ``` **Important**: Use consistent heading ID patterns: - Service overview: `{\#services-servicename}` - Features: `{\#services-servicename-features}` - Usage sections: `{\#services-servicename-basic}`, `{\#services-servicename-ssl}`, etc. - Options: `{\#services-servicename-options}` Note: Replace `servicename` with your actual service name (e.g., `nzbget`, `jellyfin`). For the `@OPTIONS_JSON@` to work, a line must be added in the `flake.nix` file: ```nix packages.manualHtml = pkgs.callPackage ./docs { modules = { "blocks/authelia" = ./modules/blocks/authelia.nix; // Add line and keep in alphabetical order. }; }; ``` ### 7. Update Redirects Automatically {#update-redirects-automatically} After creating documentation, generate the required redirects: ```bash # Scan documentation and add missing redirects nix run .#update-redirects # Review the changes git diff docs/redirects.json # The tool will show what redirects were added ``` The automation will: - Find all heading IDs in your documentation - Generate appropriate redirect entries - Add them to `docs/redirects.json` - Follow established naming patterns ### 8. Handle Unfree Dependencies {#handle-unfree-dependencies} If the service requires unfree packages: ```nix # In flake.nix config = { allowUnfree = true; permittedInsecurePackages = [ # List any required insecure packages ]; }; ``` Update CI workflow if needed: ```yaml # In .github/workflows/build.yaml - name: Setup Nix uses: cachix/install-nix-action@v31 with: extra_nix_config: | allow-unfree = true ``` ## Testing and Validation {#testing-and-validation} ### Local Testing {#local-testing} ```bash # Test redirect automation nix run .#update-redirects # Test all service variants (replace ${system} with your system, e.g., x86_64-linux) nix build .#checks.${system}.vm_servicename_basic nix build .#checks.${system}.vm_servicename_backup nix build .#checks.${system}.vm_servicename_https nix build .#checks.${system}.vm_servicename_ldap nix build .#checks.${system}.vm_servicename_monitoring nix build .#checks.${system}.vm_servicename_sso # Or run all tests (as recommended in docs/contributing.md) nix flake check # For interactive testing and debugging, see docs/contributing.md: # nix run .#checks.${system}.vm_servicename_basic.driverInteractive # Test documentation build (includes redirect validation) nix build .#manualHtml ``` To continuously rebuild the documentation of file change, run the following command. To exit, you'll need to do Ctrl-C twice in a row. ```bash nix run .#manualHtml-watch ``` ### Iterative Development Approach {#iterative-development-approach} 1. **Start with basic functionality** - get core service working 2. **Add SSL integration** - enable HTTPS 3. **Add backup integration** - ensure data protection 4. **Add monitoring** - implement health checks 5. **Add authentication** - LDAP and SSO integration 6. **Create documentation** - write service documentation with heading IDs 7. **Update redirects** - run `nix run .#update-redirects` to generate redirects 8. **Comprehensive testing** - all 6 test variants 9. **Final validation** - ensure documentation builds correctly ## Common Pitfalls and Solutions {#common-pitfalls-and-solutions} ### Configuration Issues {#configuration-issues} - **Problem**: Service doesn't start due to config validation - **Solution**: Use `lib.mkDefault` for user settings, `lib.mkForce` for security settings ### Authentication Integration {#authentication-integration-pitfalls} - **Problem**: SSO redirect loops or access denied - **Solution**: Check `autheliaRules` bypass patterns for API endpoints ### Monitoring Failures {#monitoring-failures} - **Problem**: Prometheus scraping fails with 404 - **Solution**: Verify the actual API endpoints the service provides ### Test Failures {#test-failures} - **Problem**: VM tests timeout or fail connectivity - **Solution**: Check `waitForServices` and `waitForPorts` configurations ### Nixpkgs Integration {#nixpkgs-integration} - **Problem**: Service options don't match SHB needs - **Solution**: Map SHB options to nixpkgs options, use `extraConfig` for overrides ## Best Practices Summary {#best-practices-summary} 1. **Follow existing patterns** - study deluge.nix and vaultwarden.nix 2. **Use freeform configuration** - maximum flexibility with typed key options 3. **Implement all contracts** - SSL, backup, monitoring, secrets 4. **Test comprehensively** - all 6 integration variants 5. **Security first** - localhost binding, proper permissions, secret management 6. **Document thoroughly** - clear descriptions for all options 7. **Iterative development** - build complexity gradually 8. **CI/CD validation** - ensure all tests pass before submission ## Redirect Management {#redirect-management} SelfHostBlocks uses `nixos-render-docs` for documentation generation, which includes built-in redirect validation. The `docs/redirects.json` file maps documentation identifiers to their target URLs. ### Automated Redirect Generation {#automated-redirect-generation} SelfHostBlocks includes an automated redirect management tool that leverages the official `nixos-render-docs` ecosystem: ```bash # Generate fresh redirects from HTML documentation nix run .#update-redirects ``` This tool: - **Generates HTML documentation** using `nixos-render-docs` with redirect collection enabled - **Scans actual HTML files** for anchor IDs to ensure perfect accuracy - **Creates fresh redirects** from scratch by mapping anchors to their real file locations - **Filters system-generated anchors** (excludes `opt-*` and `selfhostblock*` entries) - **Provides interactive confirmation** before updating `docs/redirects.json` ### How Redirects Work {#how-redirects-work} 1. **nixos-render-docs validation**: During documentation builds, `nixos-render-docs` automatically validates that all heading IDs have corresponding redirect entries 2. **Automated maintenance**: The `update-redirects` tool automatically maintains `redirects.json` by: - Building HTML documentation with patched `nixos-render-docs` - Scanning generated HTML files for actual anchor IDs and their file locations - Creating accurate redirect mappings without guesswork or pattern matching 3. **Manual override**: You can still manually edit `docs/redirects.json` for special cases ### Redirect Patterns {#redirect-patterns} The automation follows these patterns when mapping headings to redirect targets: | Heading ID | Source File | Redirect Target | |------------|-------------|-----------------| | `services-nzbget-basic` | `modules/services/nzbget/docs/default.md` | `["services-nzbget.html#services-nzbget-basic"]` | | `blocks-monitoring` | `modules/blocks/monitoring/docs/default.md` | `["blocks-monitoring.html#blocks-monitoring"]` | | `demo-nextcloud` | `demo/nextcloud/README.md` | `["demo-nextcloud.html#demo-nextcloud"]` | | `contracts` | `docs/contracts.md` | `["contracts.html#contracts"]` | Note: Redirects always include the anchor link (`#heading-id`) to jump to the specific heading within the target page. ### Adding New Service Documentation {#adding-new-service-documentation} When implementing a new service, the redirect workflow is now automated: 1. **Write documentation** with heading IDs: ```markdown # NewService {\#services-newservice} ## Basic Configuration {\#services-newservice-basic} ``` 2. **Update redirects automatically**: ```bash nix run .#update-redirects ``` 3. **Review and commit** the changes: ```bash git add docs/redirects.json modules/services/newservice/docs/default.md git commit -m "Add newservice documentation" ``` ### Build-time Validation {#build-time-validation} The documentation build process will fail if: - Any documentation heading ID lacks a corresponding redirect entry - Redirect targets point to non-existent content - There are formatting errors in the redirects file This ensures documentation links remain functional when content is moved or reorganized. ## Resources {#resources} - **Contributing guide**: `docs/contributing.md` for authoritative development workflows and testing procedures - **Existing services**: `modules/services/` for patterns and implementation examples - **Contracts documentation**: `modules/contracts/` for understanding integration interfaces - **Test framework**: `test/common.nix` for testing utilities and patterns - **NixOS options**: https://search.nixos.org/options for upstream service options - **SHB documentation**: Generated docs showing existing service patterns - **Redirect automation**: `nix run .#update-redirects` for automated redirect management - **nixos-render-docs**: Built-in redirect validation and documentation generation ## Quick Reference {#quick-reference} ### Complete Workflow {#complete-workflow} ```bash # 1. Implement service module vim modules/services/SERVICENAME.nix # 2. Create tests vim test/services/SERVICENAME.nix # 3. Update flake vim flake.nix # Add to allModules and checks # 4. Write documentation vim modules/services/SERVICENAME/docs/default.md # 5. Generate redirects nix run .#update-redirects # 6. Test everything nix flake check # Run all tests (recommended) # Or test specific variants: # nix build .#checks.${system}.vm_SERVICENAME_basic nix build .#manualHtml # 7. Commit changes git add . git commit -m "Add SERVICENAME with full integration" ``` This guide provides a complete roadmap for implementing production-ready SelfHostBlocks services that meet the project's quality standards. ================================================ FILE: docs/services.md ================================================ # Services {#services} Services are usually web applications that SHB help you self-host some of your data. Configuration of those is purposely made more opinionated than the upstream nixpkgs modules in exchange for an uniformized configuration experience. That is possible thanks to the extensive use of blocks provided by SHB. ::: {.note} Not all services are yet documented. You can find all available services [in the repository](@REPO@/modules/services). ::: The following table summarizes for each documented service what features it provides. More information is provided in the respective manual sections. | Service | Backup | Reverse Proxy | SSO | LDAP | Monitoring | Profiling | |-----------------------------|--------|---------------|-----|-------|------------|-----------| | [*Arr][] | Y (1) | Y | Y | Y (4) | Y (2) | N | | [Firefly-iii][] | Y (1) | Y | Y | Y | Y (2) | N | | [Forgejo][] | Y (1) | Y | Y | Y | Y (2) | N | | [Home-Assistant][] | Y (1) | Y | N | Y | Y (2) | N | | [Homepage][] | Y (1) | Y | N | Y | Y (2) | N | | [Jellyfin][] | Y (1) | Y | Y | Y | Y (2) | N | | [Karakeep][] | Y (1) | Y | Y | Y | Y (2) | N | | [Nextcloud Server][] | Y (1) | Y | Y | Y | Y (2) | P (3) | | [Open WebUI][] | Y (1) | Y | Y | Y | Y (2) | N | | [Pinchflat][] | Y | Y | Y | Y (4) | Y (5) | N | | [Simple NixOS Mailserver][] | Y | Y | N | Y | Y | N | | [Vaultwarden][] | Y (1) | Y | Y | Y | Y (2) | N | Legend: **N**: no but WIP; **P**: partial; **Y**: yes 1. Database and data files are backed up separately. This could lead to backups not being in sync. Any idea on how to fix this is welcomed! 2. Dashboard is common to all services. 3. Works but the traces are not exported to Grafana yet. 4. Uses LDAP indirectly through forward auth. [*Arr]: services-arr.html [Firefly-iii]: services-firefly-iii.html [Forgejo]: services-forgejo.html [Home-Assistant]: services-home-assistant.html [Homepage]: services-homepage.html [Jellyfin]: services-jellyfin.html [Karakeep]: services-karakeep.html [Nextcloud Server]: services-nextcloud.html [Open WebUI]: services-open-webui.html [Pinchflat]: services-pinchflat.html [Simple NixOS Mailserver]: services-mailserver.html [Vaultwarden]: services-vaultwarden.html ## Dashboard {#services-category-dashboard} ```{=include=} chapters html:into-file=//services-homepage.html modules/services/homepage/docs/default.md ``` ## Documents {#services-category-documents} ```{=include=} chapters html:into-file=//services-nextcloud.html modules/services/nextcloud-server/docs/default.md ``` ## Emails {#services-category-emails} ```{=include=} chapters html:into-file=//services-mailserver.html modules/services/mailserver/docs/default.md ``` ## Passwords {#services-category-passwords} ```{=include=} chapters html:into-file=//services-vaultwarden.html modules/services/vaultwarden/docs/default.md ``` ## Automation {#services-category-automation} ```{=include=} chapters html:into-file=//services-home-assistant.html modules/services/home-assistant/docs/default.md ``` ## AI {#services-category-ai} ```{=include=} chapters html:into-file=//services-karakeep.html modules/services/karakeep/docs/default.md ``` ```{=include=} chapters html:into-file=//services-open-webui.html modules/services/open-webui/docs/default.md ``` ## Code {#services-category-code} ```{=include=} chapters html:into-file=//services-forgejo.html modules/services/forgejo/docs/default.md ``` ## Media {#services-category-media} ```{=include=} chapters html:into-file=//services-arr.html modules/services/arr/docs/default.md ``` ```{=include=} chapters html:into-file=//services-jellyfin.html modules/services/jellyfin/docs/default.md ``` ```{=include=} chapters html:into-file=//services-pinchflat.html modules/services/pinchflat/docs/default.md ``` ## Finance {#services-category-finance} ```{=include=} chapters html:into-file=//services-firefly-iii.html modules/services/firefly-iii/docs/default.md ``` ================================================ FILE: docs/usage.md ================================================ # Usage {#usage} ## Flake {#usage-flake} Self Host Blocks (SHB) is available as a flake. It also uses its own `pkgs.lib` and `nixpkgs` and it is required to use the provided ones as input for your deployments, otherwise you might end up blocked when SHB patches a module, function or package. The following snippet is thus required to use Self Host Blocks: ```nix { inputs.selfhostblocks.url = "github:ibizaman/selfhostblocks"; outputs = { selfhostblocks, ... }: let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; in nixosConfigurations = { myserver = nixpkgs'.nixosSystem { inherit system; modules = [ selfhostblocks.nixosModules.default ./configuration.nix ]; }; }; } ``` ::: {.note} In case somehow this documentation became stale, look at the examples in [`./demo/minimal/flake.nix`](@REPO@/demo/minimal/flake.nix) which provides examples tested in CI - so assured to always be up to date - on how to use SHB. ::: ### Modules {#usage-flake-modules} The `default` module imports all modules except the SOPS module. That module is only needed if you want to use [sops-nix](#usage-secrets) to manage secrets. You can also import each module individually. You might want to do this to only import SHB overlays if you actually intend to use them. Importing the `nextcloud` module for example will anyway transitively import needed support modules so you can't go wrong: ```diff modules = [ - selfhostblocks.nixosModules.default + selfhostblocks.nixosModules.nextcloud ./configuration.nix ]; ``` To list all modules, run: ```bash $ nix flake show github:ibizaman/selfhostblocks --allow-import-from-derivation ... ├───nixosModules │ ├───arr: NixOS module │ ├───audiobookshelf: NixOS module │ ├───authelia: NixOS module │ ├───borgbackup: NixOS module │ ├───davfs: NixOS module │ ├───default: NixOS module │ ├───deluge: NixOS module │ ├───forgejo: NixOS module │ ├───grocy: NixOS module │ ├───hardcodedsecret: NixOS module │ ├───hledger: NixOS module │ ├───home-assistant: NixOS module │ ├───immich: NixOS module │ ├───jellyfin: NixOS module │ ├───karakeep: NixOS module │ ├───lib: NixOS module │ ├───lldap: NixOS module │ ├───mitmdump: NixOS module │ ├───monitoring: NixOS module │ ├───nextcloud-server: NixOS module │ ├───nginx: NixOS module │ ├───open-webui: NixOS module │ ├───paperless: NixOS module │ ├───pinchflat: NixOS module │ ├───postgresql: NixOS module │ ├───restic: NixOS module │ ├───sops: NixOS module │ ├───ssl: NixOS module │ ├───tinyproxy: NixOS module │ ├───vaultwarden: NixOS module │ ├───vpn: NixOS module │ └───zfs: NixOS module ... ``` ### Patches {#usage-flake-patches} To add your own patches on top of the patches provided by SHB, you can remove the `patchedNixpkgs` line and instead apply the patches yourself: ```diff - nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; + pkgs = import selfhostblocks.inputs.nixpkgs { + inherit system; + }; + nixpkgs' = pkgs.applyPatches { + name = "nixpkgs-patched"; + src = selfhostblocks.inputs.nixpkgs; + patches = selfhostblocks.lib.${system}.patches; + }; + nixosSystem' = import "${nixpkgs'}/nixos/lib/eval-config.nix"; in nixosConfigurations = { - myserver = nixpkgs'.nixosSystem { + myserver = nixosSystem' { ``` ### Overlays {#usage-flake-overlays} SHB applies its own overlays using `nixpkgs.overlays`. Each module provided by SHB set that option if needed. If you don't want to have those overlays applied for modules you don't intend to use SHB for, you will want to avoid importing the `default` module and instead import only the module for the services or blocks you intend to use, like shows in the [Modules](#usage-flake-modules) section. ### Substituter {#usage-flake-substituter} You can also use the public cache as a substituter with: ```nix nix.settings.trusted-public-keys = [ "selfhostblocks.cachix.org-1:H5h6Uj188DObUJDbEbSAwc377uvcjSFOfpxyCFP7cVs=" ]; nix.settings.substituters = [ "https://selfhostblocks.cachix.org" ]; ``` ### Unfree {#usage-flake-unfree} SHB does not necessarily attempt to provide only free packages. Currently, the only module using unfree modules is the [Open WebUI](@REPO@/modules/services/open-webui.nix) one. To be able to use that module, you can follow the [nixpkgs manual](https://nixos.org/manual/nixpkgs/stable/#sec-allow-unfree) and set either: ```nix { nixpkgs.config.allowUnfree = true; } ``` or the option `nixpkgs.config.allowUnfreePredicate`. ### Tag Updates {#usage-flake-tag} To pin SHB to a release/tag, you can either use an implicit or explicit way. #### Implicit {#usage-flake-tag-implicit} Here, use the usual `inputs` form: ```nix { inputs.selfhostblocks.url = "github:ibizaman/selfhostblocks"; } ``` then use the `flake update --override-input` command: ```bash nix flake update selfhostblocks \ --override-input selfhostblocks github:ibizaman/selfhostblocks/@VERSION@ ``` Note that running `nix flake update` will update the version of SHB to the latest from the main branch, canceling the override you just did above. So beware when running that command. #### Explicit {#usage-flake-tag-explicit} Here, set the version in the input directly: ```nix { inputs.selfhostblocks.url = "github:ibizaman/selfhostblocks?ref=@VERSION@"; } ``` Note that running `nix flake update` in this case will not update SHB, you must update the tag explicitly then run `nix flake update`. ### Auto Updates {#usage-flake-autoupdate} To avoid burden on the maintainers to keep `nixpkgs` input updated with upstream, the [GitHub repository][repo] for SHB updates the `nixpkgs` input every couple days, and verifies all tests pass before automatically merging the new `nixpkgs` version. The setup is explained in [this blog post][automerge]. [repo]: https://github.com/ibizaman/selfhostblocks [automerge]: https://blog.tiserbox.com/posts/2023-12-25-automated-flake-lock-update-pull-requests-and-merging.html ### Lib {#usage-flake-lib} The `selfhostblocks.nixosModules.lib` module adds a module argument called `shb` by setting the `_module.args.shb` option. It is imported by nearly all other SHB modules but you could still import it on its own if you want to access SHB's functions and no other module. The library of functions is also available under the traditional `selfhostblocks.lib` flake output. The functions layout is, in pseudo-code: - `shb.*` all functions from [`./lib/default.nix`](@REPO@/lib/default.nix). - `shb.contracts.*` all functions from [`./modules/contracts/default.nix`](@REPO@/modules/contracts/default.nix). - `shb.test.*` all functions from [`./test/common.nix`](@REPO@/test/common.nix). ## Example Deployments {#usage-examples} ### With Nixos-Rebuild {#usage-examples-nixosrebuild} The following snippets show how to deploy SHB using the standard deployment system [nixos-rebuild][nixos-rebuild]. [nixos-rebuild]: https://nixos.org/manual/nixos/stable/#sec-changing-config ```nix { inputs = { selfhostblocks.url = "github:ibizaman/selfhostblocks"; }; outputs = { self, selfhostblocks }: let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; in { nixosConfigurations = { machine = nixpkgs'.nixosSystem { inherit system; modules = [ selfhostblocks.nixosModules.default ]; }; }; }; } ``` The above snippet assumes one machine to deploy to, so `nixpkgs` is defined exclusively by the `selfhostblocks` input. It is more likely that you have multiple machines, some not using SHB, then you can do the following: ```nix { inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; selfhostblocks.url = "github:ibizaman/selfhostblocks"; }; outputs = { self, selfhostblocks }: { let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; in nixosConfigurations = { machine1 = nixpkgs.lib.nixosSystem { }; machine2 = nixpkgs'.lib.nixosSystem { system = "x86_64-linux"; modules = [ selfhostblocks.nixosModules.default ]; }; }; }; } ``` In the above snippet, `machine1` will use the `nixpkgs` version from your inputs while `machine2` will use the `nixpkgs` version from `selfhostblocks`. ### With Colmena {#usage-examples-colmena} The following snippets show how to deploy SHB using the deployment system [Colmena][Colmena]. [colmena]: https://colmena.cli.rs ```nix { inputs = { selfhostblocks.url = "github:ibizaman/selfhostblocks"; }; outputs = { self, selfhostblocks }: { let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; pkgs' = import nixpkgs' { inherit system; }; in colmena = { meta = { nixpkgs = pkgs'; }; machine = { selfhostblocks, ... }: { imports = [ selfhostblocks.nixosModules.default ]; }; }; }; } ``` The above snippet assumes one machine to deploy to, so `nixpkgs` is defined exclusively by the `selfhostblocks` input. It is more likely that you have multiple machines, some not using SHB, in this case you can use the `colmena.meta.nodeNixpkgs` option: ```nix { inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; selfhostblocks.url = "github:ibizaman/selfhostblocks"; }; outputs = { self, selfhostblocks }: { let system = "x86_64-linux"; pkgs = import nixpkgs { inherit system; }; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; pkgs' = import nixpkgs' { inherit system; }; in colmena = { meta = { nixpkgs = pkgs; nodeNixpkgs = { machine2 = pkgs'; }; }; machine1 = ...; machine2 = { selfhostblocks, ... }: { imports = [ selfhostblocks.nixosModules.default ]; # Machine specific configuration goes here. }; }; }; } ``` In the above snippet, `machine1` will use the `nixpkgs` version from your inputs while `machine2` will use the `nixpkgs` version from `selfhostblocks`. ### With Deploy-rs {#usage-examples-deploy-rs} The following snippets show how to deploy SHB using the deployment system [deploy-rs][deploy-rs]. [deploy-rs]: https://github.com/serokell/deploy-rs ```nix { inputs = { selfhostblocks.url = "github:ibizaman/selfhostblocks"; }; outputs = { self, selfhostblocks }: { let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; pkgs' = import nixpkgs' { inherit system; }; deployPkgs = import selfhostblocks.inputs.nixpkgs { inherit system; overlays = [ deploy-rs.overlay (self: super: { deploy-rs = { inherit (pkgs') deploy-rs; lib = super.deploy-rs.lib; }; }) ]; }; in nixosModules.machine = { imports = [ selfhostblocks.nixosModules.default ]; }; nixosConfigurations.machine = nixpkgs'.nixosSystem { inherit system; modules = [ self.nixosModules.machine ]; }; deploy.nodes.machine = { hostname = ...; sshUser = ...; sshOpts = [ ... ]; profiles = { system = { user = "root"; path = deployPkgs.deploy-rs.lib.activate.nixos self.nixosConfigurations.machine; }; }; }; # From https://github.com/serokell/deploy-rs?tab=readme-ov-file#overall-usage checks = builtins.mapAttrs (system: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib; }; } ``` The above snippet assumes one machine to deploy to, so `nixpkgs` is defined exclusively by the `selfhostblocks` input. It is more likely that you have multiple machines, some not using SHB, in this case you can do: ```nix { inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; selfhostblocks.url = "github:ibizaman/selfhostblocks"; }; outputs = { self, selfhostblocks }: { let system = "x86_64-linux"; nixpkgs' = selfhostblocks.lib.${system}.patchedNixpkgs; pkgs' = import nixpkgs' { inherit system; }; deployPkgs = import selfhostblocks.inputs.nixpkgs { inherit system; overlays = [ deploy-rs.overlay (self: super: { deploy-rs = { inherit (pkgs') deploy-rs; lib = super.deploy-rs.lib; }; }) ]; }; in nixosModules.machine1 = { # ... }; nixosModules.machine2 = { imports = [ selfhostblocks.nixosModules.default ]; }; nixosConfigurations.machine1 = nixpkgs.lib.nixosSystem { inherit system; modules = [ self.nixosModules.machine1 ]; }; nixosConfigurations.machine2 = nixpkgs'.nixosSystem { inherit system; modules = [ self.nixosModules.machine2 ]; }; deploy.nodes.machine1 = { hostname = ...; sshUser = ...; sshOpts = [ ... ]; profiles = { system = { user = "root"; path = deployPkgs.deploy-rs.lib.activate.nixos self.nixosConfigurations.machine1; }; }; }; deploy.nodes.machine2 = # Similar here # From https://github.com/serokell/deploy-rs?tab=readme-ov-file#overall-usage checks = builtins.mapAttrs (system: deployLib: deployLib.deployChecks self.deploy) deploy-rs.lib; }; } ``` In the above snippet, `machine1` will use the `nixpkgs` version from your inputs while `machine2` will use the `nixpkgs` version from `selfhostblocks`. ## Secrets with sops-nix {#usage-secrets} This section complements the official [sops-nix](https://github.com/Mic92/sops-nix) guide. Managing secrets is an important aspect of deploying. You cannot store your secrets in nix directly because they get stored unencrypted and you don't want that. We need to use another system that encrypts secrets when storing in the nix store and then decrypts them on the target host upon system activation. `sops-nix` is one of such system. Sops-nix works by encrypting the secrets file with at least 2 keys. Your private key and a private key from the target host. This way, you can edit the secrets and the target host can decrypt the secrets. Separating the keys this way is good practice because it reduces the impact of having one being compromised. One way to setup secrets management using `sops-nix`: 1. Create your own private key that will be located in `keys.txt`. The public key will be printed on stdout. ```bash $ nix shell nixpkgs#age --command age-keygen -o keys.txt Public key: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 ``` 2. Get the target host's public key. We will use the key derived from the ssh key of the host. ```bash $ nix shell nixpkgs#ssh-to-age --command \ sh -c 'ssh-keyscan -t ed25519 -4 | ssh-to-age' # localhost:2222 SSH-2.0-OpenSSH_9.6 age13wgyyae8epyw894ugd0rjjljh0rm98aurvzmsapcv7d852g9r5lq0pqfx8 ``` 3. Create a `sops.yaml` file that explains how sops-nix should encrypt the - yet to be created - `secrets.yaml` file. You can be creative here, but a basic snippet is: ```bash keys: - &me age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 - &target age13wgyyae8epyw894ugd0rjjljh0rm98aurvzmsapcv7d852g9r5lq0pqfx8 creation_rules: - path_regex: secrets.yaml$ key_groups: - age: - *me - *target ``` 4. Create a `secrets.yaml` file that will contain the encrypted secrets as a Yaml file: ```bash $ SOPS_AGE_KEY_FILE=keys.txt nix run --impure nixpkgs#sops -- \ secrets.yaml ``` This will open your preferred editor. An example of yaml file is the following (secrets are elided for brevity): ```yaml nextcloud: adminpass: 43bb4b... onlyoffice: jwt_secret: 3a10fce3... ``` The actual file on your filesystem will look like so, again with data elided: ```yaml nextcloud: adminpass: ENC[AES256_GCM,data:Tt99...GY=,tag:XlAqRYidkOMRZAPBsoeEMw==,type:str] onlyoffice: jwt_secret: ENC[AES256_GCM,data:f87a...Yg=,tag:Y1Vg2WqDnJbl1Xg2B6W1Hg==,type:str] sops: kms: [] gcp_kms: [] azure_kv: [] hc_vault: [] age: - recipient: age1algdv9xwjre3tm7969eyremfw2ftx4h8qehmmjzksrv7f2qve9dqg8pug7 enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdl...6g== -----END AGE ENCRYPTED FILE----- - recipient: age13wgyyae8epyw894ugd0rjjljh0rm98aurvzmsapcv7d852g9r5lq0pqfx8 enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdl...RA== -----END AGE ENCRYPTED FILE----- lastmodified: "2024-01-28T06:07:02Z" mac: ENC[AES256_GCM,data:lDJh...To=,tag:Opon9lMZBv5S7rRhkGFuQQ==,type:str] pgp: [] unencrypted_suffix: _unencrypted version: 3.8.1 ``` To actually create random secrets, you can use: ```bash $ nix run nixpkgs#openssl -- rand -hex 64 ``` 5. Use `sops-nix` module in nix: ```bash imports = [ inputs.sops-nix.nixosModules.default inputs.selfhostblocks.nixosModules.sops ]; ``` Import also the `sops` module provided by SHB. 6. Set default sops file: ```bash sops.defaultSopsFile = ./secrets.yaml; ``` Setting the default this way makes all sops instances use that same file. 7. Reference the secrets in nix: ```nix shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request; shb.nextcloud.adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result; ``` The above snippet uses the [secrets contract](./contracts-secret.html) and [sops block](./blocks-sops.html) to ease the configuration. ================================================ FILE: flake.nix ================================================ { description = "SelfHostBlocks module"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nix-flake-tests.url = "github:antifuchs/nix-flake-tests"; flake-utils.url = "github:numtide/flake-utils"; nmdsrc = { url = "git+https://git.sr.ht/~rycee/nmd"; flake = false; }; }; outputs = inputs@{ self, nixpkgs, nix-flake-tests, flake-utils, nmdsrc, ... }: let shbPatches = system: nixpkgs.legacyPackages.${system}.lib.optionals (system == "x86_64-linux" || system == "aarch64-linux") [ # Get rid of lldap patches when https://github.com/NixOS/nixpkgs/pull/425923 is merged. ./patches/lldap.patch ./patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch # Leaving commented out as an example. # (originPkgs.fetchpatch { # url = "https://github.com/NixOS/nixpkgs/pull/317107.patch"; # hash = "sha256-hoLrqV7XtR1hP/m0rV9hjYUBtrSjay0qcPUYlKKuVWk="; # }) ]; patchNixpkgs = { nixpkgs, patches, system, }: nixpkgs.legacyPackages.${system}.applyPatches { name = "nixpkgs-patched"; src = nixpkgs; inherit patches; }; patchedNixpkgs = system: let patched = patchNixpkgs { nixpkgs = inputs.nixpkgs; patches = shbPatches system; inherit system; }; in patched // { nixosSystem = args: import "${patched}/nixos/lib/eval-config.nix" args; }; pkgs' = system: import (patchedNixpkgs system) { inherit system; config.allowUnfree = true; }; in flake-utils.lib.eachDefaultSystem ( system: let pkgs = pkgs' system; # The contract dummies are used to show options for contracts. contractDummyModules = [ modules/contracts/backup/dummyModule.nix modules/contracts/dashboard/dummyModule.nix modules/contracts/databasebackup/dummyModule.nix modules/contracts/secret/dummyModule.nix modules/contracts/ssl/dummyModule.nix ]; in { formatter = pkgs.nixfmt-tree; packages.manualHtml = pkgs.callPackage ./docs { inherit nmdsrc; allModules = self.nixosModules.default.imports ++ [ self.nixosModules.sops ] ++ contractDummyModules; release = builtins.readFile ./VERSION; substituteVersionIn = [ "./manual.md" "./usage.md" ]; modules = { "blocks/authelia" = ./modules/blocks/authelia.nix; "blocks/borgbackup" = ./modules/blocks/borgbackup.nix; "blocks/lldap" = ./modules/blocks/lldap.nix; "blocks/ssl" = { module = ./modules/blocks/ssl.nix; optionRoot = [ "shb" "certs" ]; }; "blocks/mitmdump" = ./modules/blocks/mitmdump.nix; "blocks/monitoring" = ./modules/blocks/monitoring.nix; "blocks/nginx" = ./modules/blocks/nginx.nix; "blocks/postgresql" = ./modules/blocks/postgresql.nix; "blocks/restic" = ./modules/blocks/restic.nix; "blocks/sops" = ./modules/blocks/sops.nix; "services/arr" = ./modules/services/arr.nix; "services/firefly-iii" = ./modules/services/firefly-iii.nix; "services/forgejo" = [ ./modules/services/forgejo.nix (pkgs.path + "/nixos/modules/services/misc/forgejo.nix") ]; "services/home-assistant" = ./modules/services/home-assistant.nix; "services/homepage" = ./modules/services/homepage.nix; "services/jellyfin" = ./modules/services/jellyfin.nix; "services/karakeep" = ./modules/services/karakeep.nix; "services/mailserver" = ./modules/services/mailserver.nix; "services/nextcloud-server" = { module = ./modules/services/nextcloud-server.nix; optionRoot = [ "shb" "nextcloud" ]; }; "services/open-webui" = ./modules/services/open-webui.nix; "services/pinchflat" = ./modules/services/pinchflat.nix; "services/vaultwarden" = ./modules/services/vaultwarden.nix; "contracts/backup" = { module = ./modules/contracts/backup/dummyModule.nix; optionRoot = [ "shb" "contracts" "backup" ]; }; "contracts/dashboard" = { module = ./modules/contracts/dashboard/dummyModule.nix; optionRoot = [ "shb" "contracts" "dashboard" ]; }; "contracts/databasebackup" = { module = ./modules/contracts/databasebackup/dummyModule.nix; optionRoot = [ "shb" "contracts" "databasebackup" ]; }; "contracts/secret" = { module = ./modules/contracts/secret/dummyModule.nix; optionRoot = [ "shb" "contracts" "secret" ]; }; "contracts/ssl" = { module = ./modules/contracts/ssl/dummyModule.nix; optionRoot = [ "shb" "contracts" "ssl" ]; }; }; }; # Documentation redirect generation tool - scans HTML files for anchor mappings packages.generateRedirects = let # Python patch to inject redirect collector pythonPatch = pkgs.writeText "nixos-render-docs-patch.py" '' # Load redirect collector patch try: import sys, os sys.path.insert(0, os.path.dirname(__file__) + '/..') import missing_refs_collector except Exception as e: print(f"Warning: Failed to load redirect collector: {e}", file=sys.stderr) ''; # Patched nixos-render-docs that collects redirects during HTML generation nixos-render-docs-patched = pkgs.writeShellApplication { name = "nixos-render-docs"; runtimeInputs = [ pkgs.nixos-render-docs ]; text = '' TEMP_DIR=$(mktemp -d); trap 'rm -rf "$TEMP_DIR"' EXIT cp -r ${pkgs.nixos-render-docs}/${pkgs.python3.sitePackages}/nixos_render_docs "$TEMP_DIR/" chmod -R +w "$TEMP_DIR" cp ${./docs/generate-redirects-nixos-render-docs.py} "$TEMP_DIR/missing_refs_collector.py" echo '{}' > "$TEMP_DIR/empty_redirects.json" cat ${pythonPatch} >> "$TEMP_DIR/nixos_render_docs/__init__.py" ARGS=() while [[ $# -gt 0 ]]; do case $1 in --redirects) ARGS+=("$1" "$TEMP_DIR/empty_redirects.json"); shift 2 ;; *) ARGS+=("$1"); shift ;; esac done export PYTHONPATH="$TEMP_DIR:''${PYTHONPATH:-}" nixos-render-docs "''${ARGS[@]}" ''; }; in (self.packages.${system}.manualHtml.override { nixos-render-docs = nixos-render-docs-patched; }).overrideAttrs (old: { installPhase = '' ${old.installPhase} ln -sf share/doc/selfhostblocks/redirects.json $out/redirects.json ''; }); packages.manualHtml-watch = pkgs.writeShellApplication { name = "manualHtml-watch"; runtimeInputs = [ pkgs.findutils pkgs.entr ]; text = '' while sleep 1; do find . -name "*.nix" -o -name "*.md" \ | entr -d sh -c '(nix run --offline .#update-redirects && nix build --offline .#manualHtml)' || : done ''; }; lib = (pkgs.callPackage ./lib { }) // { test = pkgs.callPackage ./test/common.nix { }; contracts = pkgs.callPackage ./modules/contracts { shb = self.lib.${system}; }; patches = shbPatches system; inherit patchNixpkgs; patchedNixpkgs = patchedNixpkgs system; }; # To see the traces, run: # nix run .#playwright -- show-trace $(nix eval .#checks.x86_64-linux.vm_grocy_basic --raw)/trace/0.zip packages.playwright = pkgs.callPackage ( { stdenvNoCC, makeWrapper, playwright, }: stdenvNoCC.mkDerivation { name = "playwright"; src = playwright; nativeBuildInputs = [ makeWrapper ]; # No quotes around the value for LLDAP_PASSWORD because we want the value to not be enclosed in quotes. installPhase = '' makeWrapper ${pkgs.python3Packages.playwright}/bin/playwright $out/bin/playwright \ --set PLAYWRIGHT_BROWSERS_PATH ${pkgs.playwright-driver.browsers} \ --set PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS true ''; } ) { }; # Run "nix run .#update-redirects" to regenerate docs/redirects.json apps.update-redirects = { type = "app"; program = "${ pkgs.writeShellApplication { name = "update-redirects"; runtimeInputs = [ pkgs.nix pkgs.jq ]; text = '' echo "=== SelfHostBlocks Redirects Updater ===" echo "Generating fresh ./docs/redirects.json..." nix build .#generateRedirects || { echo "Error: Failed to generate redirects" >&2; exit 1; } [[ -f result/redirects.json ]] || { echo "Error: Generated redirects file not found" >&2; exit 1; } echo "Generated $(jq 'keys | length' result/redirects.json) redirects" [[ -f docs/redirects.json ]] && cp docs/redirects.json docs/redirects.json.backup && echo "Created backup" cp result/redirects.json docs/redirects.json echo " Updated docs/redirects.json" echo "To verify: nix build .#manualHtml" ''; } }/bin/update-redirects"; }; } ) // flake-utils.lib.eachSystem [ "x86_64-linux" ] ( system: let pkgs = pkgs' system; in { checks = let inherit (pkgs.lib) foldl foldlAttrs mergeAttrs ; importFiles = files: map ( m: pkgs.callPackage m { shb = self.lib.${system}; } ) files; mergeTests = foldl mergeAttrs { }; flattenAttrs = root: attrset: foldlAttrs ( acc: name: value: acc // { "${root}_${name}" = value; } ) { } attrset; vm_test = name: path: flattenAttrs "vm_${name}" ( removeAttrs (pkgs.callPackage path { shb = self.lib.${system}; }) [ "override" "overrideDerivation" ] ); in ( { modules = self.lib.${system}.check { inherit pkgs; tests = mergeTests (importFiles [ ./test/modules/davfs.nix # TODO: Make this not use IFD ./test/modules/lib.nix ]); }; # TODO: Make this not use IFD lib = nix-flake-tests.lib.check { inherit pkgs; tests = pkgs.callPackage ./test/modules/lib.nix { shb = self.lib.${system}; }; }; } // (vm_test "arr" ./test/services/arr.nix) // (vm_test "audiobookshelf" ./test/services/audiobookshelf.nix) // (vm_test "deluge" ./test/services/deluge.nix) // (vm_test "firefly-iii" ./test/services/firefly-iii.nix) // (vm_test "forgejo" ./test/services/forgejo.nix) // (vm_test "grocy" ./test/services/grocy.nix) // (vm_test "hledger" ./test/services/hledger.nix) // (vm_test "immich" ./test/services/immich.nix) // (vm_test "homeassistant" ./test/services/home-assistant.nix) // (vm_test "homepage" ./test/services/homepage.nix) // (vm_test "jellyfin" ./test/services/jellyfin.nix) // (vm_test "karakeep" ./test/services/karakeep.nix) // (vm_test "nextcloud" ./test/services/nextcloud.nix) // (vm_test "open-webui" ./test/services/open-webui.nix) // (vm_test "paperless" ./test/services/paperless.nix) // (vm_test "pinchflat" ./test/services/pinchflat.nix) // (vm_test "vaultwarden" ./test/services/vaultwarden.nix) // (vm_test "authelia" ./test/blocks/authelia.nix) // (vm_test "borgbackup" ./test/blocks/borgbackup.nix) // (vm_test "lldap" ./test/blocks/lldap.nix) // (vm_test "lib" ./test/blocks/lib.nix) // (vm_test "mitmdump" ./test/blocks/mitmdump.nix) // (vm_test "monitoring" ./test/blocks/monitoring.nix) // (vm_test "postgresql" ./test/blocks/postgresql.nix) // (vm_test "restic" ./test/blocks/restic.nix) // (vm_test "ssl" ./test/blocks/ssl.nix) // (vm_test "contracts-backup" ./test/contracts/backup.nix) // (vm_test "contracts-databasebackup" ./test/contracts/databasebackup.nix) // (vm_test "contracts-secret" ./test/contracts/secret.nix) ); } ) // { herculesCI.ciSystems = [ "x86_64-linux" ]; nixosModules.default = { imports = [ # blocks self.nixosModules.authelia self.nixosModules.borgbackup self.nixosModules.davfs self.nixosModules.hardcodedsecret self.nixosModules.lldap self.nixosModules.mitmdump self.nixosModules.monitoring self.nixosModules.nginx self.nixosModules.postgresql self.nixosModules.restic self.nixosModules.ssl self.nixosModules.tinyproxy self.nixosModules.vpn self.nixosModules.zfs # services self.nixosModules.arr self.nixosModules.audiobookshelf self.nixosModules.deluge self.nixosModules.firefly-iii self.nixosModules.forgejo self.nixosModules.grocy self.nixosModules.hledger self.nixosModules.immich self.nixosModules.home-assistant self.nixosModules.homepage self.nixosModules.jellyfin self.nixosModules.karakeep self.nixosModules.mailserver self.nixosModules.nextcloud-server self.nixosModules.open-webui self.nixosModules.pinchflat self.nixosModules.paperless self.nixosModules.vaultwarden ]; }; nixosModules.lib = lib/module.nix; nixosModules.authelia = modules/blocks/authelia.nix; nixosModules.borgbackup = modules/blocks/borgbackup.nix; nixosModules.davfs = modules/blocks/davfs.nix; nixosModules.hardcodedsecret = modules/blocks/hardcodedsecret.nix; nixosModules.lldap = modules/blocks/lldap.nix; nixosModules.mitmdump = modules/blocks/mitmdump.nix; nixosModules.monitoring = modules/blocks/monitoring.nix; nixosModules.nginx = modules/blocks/nginx.nix; nixosModules.postgresql = modules/blocks/postgresql.nix; nixosModules.restic = modules/blocks/restic.nix; nixosModules.ssl = modules/blocks/ssl.nix; nixosModules.sops = modules/blocks/sops.nix; nixosModules.tinyproxy = modules/blocks/tinyproxy.nix; nixosModules.vpn = modules/blocks/vpn.nix; nixosModules.zfs = modules/blocks/zfs.nix; nixosModules.arr = modules/services/arr.nix; nixosModules.audiobookshelf = modules/services/audiobookshelf.nix; nixosModules.deluge = modules/services/deluge.nix; nixosModules.firefly-iii = modules/services/firefly-iii.nix; nixosModules.forgejo = modules/services/forgejo.nix; nixosModules.grocy = modules/services/grocy.nix; nixosModules.hledger = modules/services/hledger.nix; nixosModules.immich = modules/services/immich.nix; nixosModules.home-assistant = modules/services/home-assistant.nix; nixosModules.homepage = modules/services/homepage.nix; nixosModules.jellyfin = modules/services/jellyfin.nix; nixosModules.karakeep = modules/services/karakeep.nix; nixosModules.mailserver = modules/services/mailserver.nix; nixosModules.nextcloud-server = modules/services/nextcloud-server.nix; nixosModules.open-webui = modules/services/open-webui.nix; nixosModules.paperless = modules/services/paperless.nix; nixosModules.pinchflat = modules/services/pinchflat.nix; nixosModules.vaultwarden = modules/services/vaultwarden.nix; }; } ================================================ FILE: lib/default.nix ================================================ { pkgs, lib }: let inherit (builtins) isAttrs hasAttr; inherit (lib) any concatMapStringsSep concatStringsSep; shb = rec { # Replace secrets in a file. # - userConfig is an attrset that will produce a config file. # - resultPath is the location the config file should have on the filesystem. # - generator is a function taking two arguments name and value and returning path in the nix # nix store where the replaceSecrets = { userConfig, resultPath, generator, user ? null, permissions ? "u=r,g=r,o=", }: let configWithTemplates = withReplacements userConfig; nonSecretConfigFile = generator "template" configWithTemplates; replacements = getReplacements userConfig; in replaceSecretsScript { file = nonSecretConfigFile; inherit resultPath replacements; inherit user permissions; }; replaceSecretsFormatAdapter = format: format.generate; replaceSecretsGeneratorAdapter = generator: name: value: pkgs.writeText "generator " (generator value); toEnvVar = replaceSecretsGeneratorAdapter ( v: (lib.generators.toINIWithGlobalSection { } { globalSection = v; }) ); template = file: newPath: replacements: replaceSecretsScript { inherit file replacements; resultPath = newPath; }; genReplacement = secret: let t = { transform ? null, ... }: if isNull transform then x: x else transform; in lib.attrsets.nameValuePair (secretName secret.name) ((t secret) "$(cat ${toString secret.source})"); replaceSecretsScript = { file, resultPath, replacements, user ? null, permissions ? "u=r,g=r,o=", }: let templatePath = resultPath + ".template"; # We check that the files containing the secrets have the # correct permissions for us to read them in this separate # step. Otherwise, the $(cat ...) commands inside the sed # replacements could fail but not fail individually but # not fail the whole script. checkPermissions = concatMapStringsSep "\n" ( pattern: "cat ${pattern.source} > /dev/null" ) replacements; sedPatterns = concatMapStringsSep " " (pattern: "-e \"s|${pattern.name}|${pattern.value}|\"") ( map genReplacement replacements ); sedCmd = if replacements == [ ] then "cat" else "${pkgs.gnused}/bin/sed ${sedPatterns}"; in '' set -euo pipefail ${checkPermissions} mkdir -p $(dirname ${templatePath}) ln -fs ${file} ${templatePath} rm -f ${resultPath} touch ${resultPath} '' + (lib.optionalString (user != null) '' chown ${user} ${resultPath} '') + '' ${sedCmd} ${templatePath} > ${resultPath} chmod ${permissions} ${resultPath} ''; secretFileType = lib.types.submodule { options = { source = lib.mkOption { type = lib.types.path; description = "File containing the value."; }; transform = lib.mkOption { type = lib.types.raw; description = "An optional function to transform the secret."; default = null; example = lib.literalExpression '' v: "prefix-$${v}-suffix" ''; }; }; }; secretName = names: "%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) names)}%"; withReplacements = attrs: let valueOrReplacement = name: value: if !(builtins.isAttrs value && value ? "source") then value else secretName name; in mapAttrsRecursiveCond (v: !v ? "source") valueOrReplacement attrs; getReplacements = attrs: let addNameField = name: value: if !(builtins.isAttrs value && value ? "source") then value else value // { name = name; }; secretsWithName = mapAttrsRecursiveCond (v: !v ? "source") addNameField attrs; in collect (v: builtins.isAttrs v && v ? "source") secretsWithName; # Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists. mapAttrsRecursiveCond = # A function, given the attribute set the recursion is currently at, determine if to recurse deeper into that attribute set. cond: # A function, given a list of attribute names and a value, returns a new value. f: # Attribute set or list to recursively map over. set: let recurse = path: val: if builtins.isAttrs val && cond val then lib.attrsets.mapAttrs (n: v: recurse (path ++ [ n ]) v) val else if builtins.isList val && cond val then lib.lists.imap0 (i: v: recurse (path ++ [ (builtins.toString i) ]) v) val else f path val; in recurse [ ] set; # Like lib.attrsets.collect but also recurses on lists. collect = # Given an attribute's value, determine if recursion should stop. pred: # The attribute set to recursively collect. attrs: if pred attrs then [ attrs ] else if builtins.isAttrs attrs then lib.lists.concatMap (collect pred) (lib.attrsets.attrValues attrs) else if builtins.isList attrs then lib.lists.concatMap (collect pred) attrs else [ ]; indent = i: str: lib.concatMapStringsSep "\n" (x: (lib.strings.replicate i " ") + x) (lib.splitString "\n" str); # Generator for XML formatXML = { enclosingRoot ? null, }: { type = with lib.types; let valueType = nullOr (oneOf [ bool int float str path (attrsOf valueType) (listOf valueType) ]) // { description = "XML value"; }; in valueType; generate = name: value: pkgs.callPackage ( { runCommand, python3 }: runCommand "config" { value = builtins.toJSON (if enclosingRoot == null then value else { ${enclosingRoot} = value; }); passAsFile = [ "value" ]; } ( pkgs.writers.writePython3 "dict2xml" { libraries = with python3.pkgs; [ python dict2xml ]; } '' import os import json from dict2xml import dict2xml with open(os.environ["valuePath"]) as f: content = json.loads(f.read()) if content is None: print("Could not parse env var valuePath as json") os.exit(2) with open(os.environ["out"], "w") as out: out.write(dict2xml(content)) '' ) ) { }; }; parseXML = xml: let xmlToJsonFile = pkgs.callPackage ( { runCommand, python3 }: runCommand "config" { inherit xml; passAsFile = [ "xml" ]; } ( pkgs.writers.writePython3 "xml2json" { libraries = with python3.pkgs; [ python ]; } '' import os import json from collections import ChainMap from xml.etree import ElementTree def xml_to_dict_recursive(root): all_descendants = list(root) if len(all_descendants) == 0: return {root.tag: root.text} else: merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants)) return {root.tag: dict(merged_dict)} with open(os.environ["xmlPath"]) as f: root = ElementTree.XML(f.read()) xml = xml_to_dict_recursive(root) j = json.dumps(xml) with open(os.environ["out"], "w") as out: out.write(j) '' ) ) { }; in builtins.fromJSON (builtins.readFile xmlToJsonFile); renameAttrName = attrset: from: to: (lib.attrsets.filterAttrs (name: v: name == from) attrset) // { ${to} = attrset.${from}; }; # Taken from https://github.com/antifuchs/nix-flake-tests/blob/main/default.nix # with a nicer diff display function. check = { pkgs, tests }: let formatValue = val: if (builtins.isList val || builtins.isAttrs val) then builtins.toJSON val else builtins.toString val; resultToString = { name, expected, result, }: builtins.readFile ( pkgs.runCommand "nix-flake-tests-error" { expected = formatValue expected; result = formatValue result; passAsFile = [ "expected" "result" ]; } '' echo "${name} failed (- expected, + result)" > $out cp ''${expectedPath} ''${expectedPath}.json cp ''${resultPath} ''${resultPath}.json ${pkgs.deepdiff}/bin/deep diff ''${expectedPath}.json ''${resultPath}.json >> $out '' ); results = pkgs.lib.runTests tests; in if results != [ ] then builtins.throw (concatStringsSep "\n" (map resultToString (lib.traceValSeq results))) else pkgs.runCommand "nix-flake-tests-success" { } "echo > $out"; genConfigOutOfBandSystemd = { config, configLocation, generator, user ? null, permissions ? "u=r,g=r,o=", }: { loadCredentials = getLoadCredentials "source" config; preStart = lib.mkBefore (replaceSecrets { userConfig = updateToLoadCredentials "source" "$CREDENTIALS_DIRECTORY" config; resultPath = configLocation; inherit generator; inherit user permissions; }); }; updateToLoadCredentials = sourceField: rootDir: attrs: let hasPlaceholderField = v: isAttrs v && hasAttr sourceField v; valueOrLoadCredential = path: value: if !(hasPlaceholderField value) then value else value // { ${sourceField} = rootDir + "/" + concatStringsSep "_" path; }; in mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) valueOrLoadCredential attrs; getLoadCredentials = sourceField: attrs: let hasPlaceholderField = v: isAttrs v && hasAttr sourceField v; addPathField = path: value: if !(hasPlaceholderField value) then value else value // { inherit path; }; secretsWithPath = mapAttrsRecursiveCond (v: !(hasPlaceholderField v)) addPathField attrs; allSecrets = collect (v: hasPlaceholderField v) secretsWithPath; genLoadCredentials = secret: "${concatStringsSep "_" secret.path}:${secret.${sourceField}}"; in map genLoadCredentials allSecrets; anyNotNull = any (x: x != null); mkJellyfinPlugin = { pname, version, hash, url, }: pkgs.callPackage ( { stdenv, fetchzip }: stdenv.mkDerivation (finalAttrs: { inherit pname version; src = fetchzip { inherit url hash; stripRoot = false; }; dontBuild = true; installPhase = '' mkdir $out cp -r . $out ''; }) ) { }; update = attr: fn: attrset: attrset // { ${attr} = fn attrset.${attr}; }; }; in shb // { homepage = pkgs.callPackage ./homepage.nix { inherit shb; }; } ================================================ FILE: lib/homepage.nix ================================================ { lib, shb }: let sort = attr: vs: map (v: { ${v.name} = v.${attr}; }) ( lib.sortOn (v: v.sortOrder) (lib.mapAttrsToList (n: v: v // { name = n; }) vs) ); slufigy = builtins.replaceStrings [ "-" ] [ "_" ]; mkService = groupName: serviceName: { request, ... }: apiKey: settings: lib.recursiveUpdate ( { href = request.externalUrl; siteMonitor = if (request.internalUrl == null) then null else request.internalUrl; icon = "sh-${lib.toLower serviceName}"; } // lib.optionalAttrs (apiKey != null) { widget = { # Duplicating because widgets call the api key various names # and duplicating is a hacky but easy solution. key = "{{HOMEPAGE_FILE_${slufigy groupName}_${slufigy serviceName}}}"; password = "{{HOMEPAGE_FILE_${slufigy groupName}_${slufigy serviceName}}}"; type = lib.toLower serviceName; url = if (request.internalUrl != null) then request.internalUrl else request.externalUrl; }; } ) settings; asServiceGroup = cfg: sort "services" ( lib.mapAttrs ( groupName: groupCfg: shb.update "services" ( services: sort "dashboard" ( lib.mapAttrs ( serviceName: serviceCfg: shb.update "dashboard" ( dashboard: (mkService groupName serviceName) dashboard serviceCfg.apiKey (serviceCfg.settings or { }) ) serviceCfg ) services ) ) groupCfg ) cfg ); allKeys = cfg: let flat = lib.flatten ( lib.mapAttrsToList ( groupName: groupCfg: lib.mapAttrsToList ( serviceName: serviceCfg: lib.optionalAttrs (serviceCfg.apiKey != null) { inherit serviceName groupName; inherit (serviceCfg.apiKey.result) path; } ) groupCfg.services ) cfg ); flatWithApiKey = builtins.filter (v: v != { }) flat; in builtins.listToAttrs ( map ( { groupName, serviceName, path, }: lib.nameValuePair "${slufigy groupName}_${slufigy serviceName}" path ) flatWithApiKey ); in { inherit allKeys asServiceGroup mkService sort ; } ================================================ FILE: lib/module.nix ================================================ { pkgs, lib, ... }: let shb = (import ./default.nix { inherit pkgs lib; }); in { _module.args.shb = shb // { test = pkgs.callPackage ../test/common.nix { }; contracts = pkgs.callPackage ../modules/contracts { inherit shb; }; }; } ================================================ FILE: modules/blocks/authelia/docs/default.md ================================================ # Authelia Block {#blocks-authelia} Defined in [`/modules/blocks/authelia.nix`](@REPO@/modules/blocks/authelia.nix). This block sets up an [Authelia][] service for Single-Sign On integration. [Authelia]: https://www.authelia.com/ Compared to the upstream nixpkgs module, this module is tightly integrated with SHB which allows easy configuration of SSO with [OIDC integration](#blocks-authelia-shb-oidc) as well as some extensive [troubleshooting](#blocks-authelia-troubleshooting) features. Note that forward authentication is configured with the [nginx block](blocks-nginx.html#blocks-nginx-usage-shbforwardauth). ## Features {#services-authelia-features} - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-authelia-usage-applicationdashboard) ## Usage {#services-authelia-usage} ### Initial Configuration {#blocks-authelia-usage-configuration} Authelia cannot work without SSL and LDAP. So setting up the Authelia block requires to setup the [SSL block][] first and the [LLDAP block][] first. [SSL block]: blocks-ssl.html [LLDAP block]: blocks-lldap.html SSL is required to encrypt the communication and LDAP is used to handle users and group assignments. Authelia will allow access to a given resource only if the user that is authenticated is a member of the corresponding LDAP group. Afterwards, assuming the LDAP service runs on the same machine, the Authelia configuration can be done with: ```nix shb.authelia = { enable = true; domain = "example.com"; subdomain = "auth"; ssl = config.shb.certs.certs.letsencrypt."example.com"; ldapHostname = "127.0.0.1"; ldapPort = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; smtp = { host = "smtp.eu.mailgun.org"; port = 587; username = "postmaster@mg.example.com"; from_address = "authelia@example.com"; password.result = config.shb.sops.secret."authelia/smtp_password".result; }; secrets = { jwtSecret.result = config.shb.sops.secret."authelia/jwt_secret".result; ldapAdminPassword.result = config.shb.sops.secret."authelia/ldap_admin_password".result; sessionSecret.result = config.shb.sops.secret."authelia/session_secret".result; storageEncryptionKey.result = config.shb.sops.secret."authelia/storage_encryption_key".result; identityProvidersOIDCHMACSecret.result = config.shb.sops.secret."authelia/hmac_secret".result; identityProvidersOIDCIssuerPrivateKey.result = config.shb.sops.secret."authelia/private_key".result; }; }; shb.certs.certs.letsencrypt."example.com".extraDomains = [ "auth.example.com" ]; shb.sops.secret."authelia/jwt_secret".request = config.shb.authelia.secrets.jwtSecret.request; shb.sops.secret."authelia/ldap_admin_password" = { request = config.shb.authelia.secrets.ldapAdminPassword.request; settings.key = "lldap/user_password"; }; shb.sops.secret."authelia/session_secret".request = config.shb.authelia.secrets.sessionSecret.request; shb.sops.secret."authelia/storage_encryption_key".request = config.shb.authelia.secrets.storageEncryptionKey.request; shb.sops.secret."authelia/hmac_secret".request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request; shb.sops.secret."authelia/private_key".request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request; shb.sops.secret."authelia/smtp_password".request = config.shb.authelia.smtp.password.request; ``` This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. It's a bit annoying to setup all those secrets but it's only necessary once. Use `nix run nixpkgs#openssl -- rand -hex 64` to generate them. Crucially, the `shb.authelia.secrets.ldapAdminPasswordFile` must be the same as the `shb.lldap.ldapUserPassword` defined for the [LLDAP block][]. This is done using Sops' `key` option. ### Application Dashboard {#services-authelia-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#blocks-authelia-options-shb.authelia.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Admin.services.Authelia = { sortOrder = 2; dashboard.request = config.shb.authelia.dashboard.request; }; } ``` ## SHB OIDC integration {#blocks-authelia-shb-oidc} For services [provided by SelfHostBlocks][services] that handle [OIDC integration][OIDC], integrating with this block is done by configuring the service itself and linking it to this Authelia block through the `endpoint` option and by sharing a secret: [services]: services.html [OIDC]: https://openid.net/developers/how-connect-works/ ```nix shb..sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secret.result = config.shb.sops.secret."/sso/secret".result; secretForAuthelia.result = config.shb.sops.secret."/sso/secretForAuthelia".result; }; shb.sops.secret."/sso/secret".request = config.shb..sso.secret.request; shb.sops.secret."/sso/secretForAuthelia" = { request = config.shb..sso.secretForAuthelia.request; settings.key = "/sso/secret"; }; ``` To share a secret between the service and Authelia, we generate a secret with `nix run nixpkgs#openssl -- rand -hex 64` under `/sso/secret` then we ask Sops to use the same password for `/sso/secretForAuthelia` thanks to the `settings.key` option. The difference between both secrets is one if owned by the `authelia` user while the other is owned by the user of the ` we are configuring. ## OIDC Integration {#blocks-authelia-oidc} To integrate a service handling OIDC integration not provided by SelfHostBlocks with this Authelia block, the necessary configuration is: ```nix shb.authelia.oidcClients = [ { client_id = ""; client_secret.source = config.shb.sops.secret."/sso/secretForAuthelia".response.path; scopes = [ "openid" "email" "profile" ]; redirect_uris = [ "" ]; } ]; shb.sops.secret."/sso/secret".request = { owner = ""; }; shb.sops.secret."/sso/secretForAuthelia" = { request.owner = "authelia"; settings.key = "/sso/secret"; }; ``` As in the previous section, we create a shared secret using Sops' `settings.key` option. The configuration for the service itself is much dependent on the service itself. For example for [open-webui][], the configuration looks like so: [open-webui]: https://search.nixos.org/options?query=services.open-webui ```nix services.open-webui.environment = { ENABLE_SIGNUP = "False"; WEBUI_AUTH = "True"; ENABLE_FORWARD_USER_INFO_HEADERS = "True"; ENABLE_OAUTH_SIGNUP = "True"; OAUTH_UPDATE_PICTURE_ON_LOGIN = "True"; OAUTH_CLIENT_ID = "open-webui"; OAUTH_CLIENT_SECRET = ""; OPENID_PROVIDER_URL = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}/.well-known/openid-configuration"; OAUTH_PROVIDER_NAME = "Single Sign-On"; OAUTH_SCOPES = "openid email profile"; OAUTH_ALLOWED_ROLES = "open-webui_user"; OAUTH_ADMIN_ROLES = "open-webui_admin"; ENABLE_OAUTH_ROLE_MANAGEMENT = "True"; }; shb.authelia.oidcClients = [ { client_id = "open-webui"; client_secret.source = config.shb.sops.secret."open-webui/sso/secretForAuthelia".response.path; scopes = [ "openid" "email" "profile" ]; redirect_uris = [ "" ]; } ]; shb.sops.secret."open-webui/sso/secret".request = { owner = "open-webui"; }; shb.sops.secret."open-webui/sso/secretForAuthelia" = { request.owner = "authelia"; settings.key = "open-webui/sso/secret"; }; ``` Here, there is no way to give a path for the `OAUTH_CLIENT_SECRET`, we are obligated to pass the raw secret which is a very bad idea. There are ways around this but they are out of scope for this section. Inspiration can be taken from SelfHostBlocks' source code. To access the UI, we will need to create an `open-webui_user` and `open-webui_admin` LDAP group and assign our user to it. ## Forward Auth {#blocks-authelia-forward-auth} Forward authentication is provided by the [nginx block](blocks-nginx.html#blocks-nginx-usage-ssl). ## Troubleshooting {#blocks-authelia-troubleshooting} Set the [debug][opt-debug] option to `true` to: [opt-debug]: #blocks-authelia-options-shb.authelia.debug - Set logging level to `"debug"`. - Add an [shb.mitmdump][] instance in front of Authelia which prints all requests and responses headers and body to the systemd service `mitmdump-authelia-${config.shb.authelia.subdomain}.${config.shb.authelia.domain}.service`. [shb.mitmdump]: ./blocks-mitmdump.html ## Tests {#blocks-authelia-tests} Specific integration tests are defined in [`/test/blocks/authelia.nix`](@REPO@/test/blocks/authelia.nix). ## Options Reference {#blocks-authelia-options} ```{=include=} options id-prefix: blocks-authelia-options- list-id: selfhostblocks-block-authelia-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/authelia.nix ================================================ { config, options, pkgs, lib, shb, ... }: let cfg = config.shb.authelia; opt = options.shb.authelia; fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdnWithPort = if isNull cfg.port then fqdn else "${fqdn}:${toString cfg.port}"; autheliaCfg = config.services.authelia.instances.${fqdn}; inherit (lib) hasPrefix; listenPort = if cfg.debug then 9090 else 9091; in { imports = [ ../../lib/module.nix ./lldap.nix ./mitmdump.nix ./postgresql.nix ]; options.shb.authelia = { enable = lib.mkEnableOption "selfhostblocks.authelia"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Authelia will be served."; example = "auth"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which Authelia will be served."; example = "mydomain.com"; }; port = lib.mkOption { description = "If given, adds a port to the `.` endpoint."; type = lib.types.nullOr lib.types.port; default = null; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; ldapHostname = lib.mkOption { type = lib.types.str; description = "Hostname of the LDAP authentication backend."; example = "ldap.example.com"; }; ldapPort = lib.mkOption { type = lib.types.port; description = "Port of the LDAP authentication backend."; example = "389"; }; dcdomain = lib.mkOption { type = lib.types.str; description = "dc domain for ldap."; example = "dc=mydomain,dc=com"; }; autheliaUser = lib.mkOption { type = lib.types.str; description = "System user for this Authelia instance."; default = "authelia"; }; secrets = lib.mkOption { description = "Secrets needed by Authelia"; type = lib.types.submodule { options = { jwtSecret = lib.mkOption { description = "JWT secret."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = cfg.autheliaUser; restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ]; }; }; }; ldapAdminPassword = lib.mkOption { description = "LDAP admin user password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = cfg.autheliaUser; restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ]; }; }; }; sessionSecret = lib.mkOption { description = "Session secret."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = cfg.autheliaUser; restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ]; }; }; }; storageEncryptionKey = lib.mkOption { description = "Storage encryption key. Must be >= 20 characters."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = cfg.autheliaUser; restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ]; }; }; }; identityProvidersOIDCHMACSecret = lib.mkOption { description = "Identity provider OIDC HMAC secret. Must be >= 40 characters."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = cfg.autheliaUser; restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ]; }; }; }; identityProvidersOIDCIssuerPrivateKey = lib.mkOption { description = '' Identity provider OIDC issuer private key. Generate one with `nix run nixpkgs#openssl -- genrsa -out keypair.pem 2048` ''; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = cfg.autheliaUser; restartUnits = [ "authelia-${opt.subdomain}.${opt.domain}.service" ]; }; }; }; }; }; }; extraOidcClaimsPolicies = lib.mkOption { description = "Extra OIDC claims policies."; type = lib.types.attrsOf lib.types.attrs; default = { }; }; extraOidcScopes = lib.mkOption { description = "Extra OIDC scopes."; type = lib.types.attrsOf lib.types.attrs; default = { }; }; extraOidcAuthorizationPolicies = lib.mkOption { description = "Extra OIDC authorization policies."; type = lib.types.attrsOf lib.types.attrs; default = { }; }; extraDefinitions = lib.mkOption { description = "Extra definitions."; type = lib.types.attrsOf lib.types.attrs; default = { }; }; oidcClients = lib.mkOption { description = "OIDC clients"; default = [ { client_id = "dummy_client"; client_name = "Dummy Client so Authelia can start"; client_secret.source = pkgs.writeText "dummy.secret" "dummy_client_secret"; public = false; authorization_policy = "one_factor"; redirect_uris = [ ]; } ]; type = lib.types.listOf ( lib.types.submodule { freeformType = lib.types.attrsOf lib.types.anything; options = { client_id = lib.mkOption { type = lib.types.str; description = "Unique identifier of the OIDC client."; }; client_name = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "Human readable description of the OIDC client."; default = null; }; client_secret = lib.mkOption { type = shb.secretFileType; description = '' File containing the shared secret with the OIDC client. Generate with: ``` nix run nixpkgs#authelia -- \ crypto hash generate pbkdf2 \ --variant sha512 \ --random \ --random.length 72 \ --random.charset rfc3986 ``` ''; }; public = lib.mkOption { type = lib.types.bool; description = "If the OIDC client is public or not."; default = false; apply = v: if v then "true" else "false"; }; authorization_policy = lib.mkOption { type = lib.types.enum ( [ "one_factor" "two_factor" ] ++ lib.attrNames cfg.extraOidcAuthorizationPolicies ); description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; redirect_uris = lib.mkOption { type = lib.types.listOf lib.types.str; description = "List of uris that are allowed to be redirected to."; }; scopes = lib.mkOption { type = lib.types.listOf lib.types.str; description = "Scopes to ask for. See https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims"; example = [ "openid" "profile" "email" "groups" ]; default = [ ]; }; claims_policy = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' Claim policy. Defaults to 'default' to provide a backwards compatible experience. Read [this document](https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter) for more information. ''; default = "default"; }; }; } ); }; smtp = lib.mkOption { description = '' If a string is given, writes notifications to the given path.Otherwise, send notifications by smtp. https://www.authelia.com/configuration/notifications/introduction/ ''; default = "/tmp/authelia-notifications"; type = lib.types.oneOf [ lib.types.str (lib.types.nullOr ( lib.types.submodule { options = { from_address = lib.mkOption { type = lib.types.str; description = "SMTP address from which the emails originate."; example = "authelia@mydomain.com"; }; from_name = lib.mkOption { type = lib.types.str; description = "SMTP name from which the emails originate."; default = "Authelia"; }; scheme = lib.mkOption { description = "The protocl must be smtp, submission, or submissions. The only difference between these schemes are the default ports and submissions requires a TLS transport per SMTP Ports Security Measures, whereas submission and smtp use a standard TCP transport and typically enforce StartTLS."; type = lib.types.enum [ "smtp" "submission" "submissions" ]; default = "smtp"; }; host = lib.mkOption { type = lib.types.str; description = "SMTP host to send the emails to."; }; port = lib.mkOption { type = lib.types.port; description = "SMTP port to send the emails to."; default = 25; }; username = lib.mkOption { type = lib.types.str; description = "Username to connect to the SMTP host."; }; password = lib.mkOption { description = "File containing the password to connect to the SMTP host."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = cfg.autheliaUser; restartUnits = [ "authelia-${fqdn}.service" ]; }; }; }; }; } )) ]; }; rules = lib.mkOption { type = lib.types.listOf lib.types.anything; description = "Rule based clients"; default = [ ]; }; mount = lib.mkOption { type = shb.contracts.mount; description = '' Mount configuration. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."authelia" = { poolName = "root"; } // config.shb.authelia.mount; ``` ''; readOnly = true; default = { path = "/var/lib/authelia-authelia.${cfg.domain}"; }; defaultText = { path = "/var/lib/authelia-authelia.example.com"; }; }; mountRedis = lib.mkOption { type = shb.contracts.mount; description = '' Mount configuration for Redis. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."redis-authelia" = { poolName = "root"; } // config.shb.authelia.mountRedis; ``` ''; readOnly = true; default = { path = "/var/lib/redis-authelia"; }; }; debug = lib.mkOption { type = lib.types.bool; default = false; description = '' Set logging level to debug and add a mitmdump instance to see exactly what Authelia receives and sends back. ''; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.authelia.subdomain}.\${config.shb.authelia.domain}"; internalUrl = "http://127.0.0.1:${toString listenPort}"; }; }; }; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = builtins.length cfg.oidcClients > 0; message = "Must have at least one oidc client otherwise Authelia refuses to start."; } { assertion = !(hasPrefix "ldap://" cfg.ldapHostname); message = "LDAP hostname should be the bare host name and not start with ldap://"; } ]; # Overriding the user name so we don't allow any weird characters anywhere. For example, postgres users do not accept the '.'. users = { groups.${autheliaCfg.user} = { }; users.${autheliaCfg.user} = { isSystemUser = true; group = autheliaCfg.user; }; }; services.authelia.instances.${fqdn} = { enable = true; user = cfg.autheliaUser; secrets = { jwtSecretFile = cfg.secrets.jwtSecret.result.path; storageEncryptionKeyFile = cfg.secrets.storageEncryptionKey.result.path; sessionSecretFile = cfg.secrets.sessionSecret.result.path; oidcIssuerPrivateKeyFile = cfg.secrets.identityProvidersOIDCIssuerPrivateKey.result.path; oidcHmacSecretFile = cfg.secrets.identityProvidersOIDCHMACSecret.result.path; }; # See https://www.authelia.com/configuration/methods/secrets/ environmentVariables = { AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE = toString cfg.secrets.ldapAdminPassword.result.path; # Not needed since we use peer auth. # AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE = "/run/secrets/authelia/postgres_password"; AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE = lib.mkIf (!(builtins.isString cfg.smtp)) ( toString cfg.smtp.password.result.path ); X_AUTHELIA_CONFIG_FILTERS = "template"; }; settings = { server.address = "tcp://127.0.0.1:${toString listenPort}"; # Inspired from https://github.com/lldap/lldap/blob/7d1f5abc137821c500de99c94f7579761fc949d8/example_configs/authelia_config.yml authentication_backend = { refresh_interval = "5m"; # We allow password reset and change because the ldap user we use allows it. password_reset.disable = "false"; password_change.disable = "false"; ldap = { implementation = "lldap"; address = "ldap://${cfg.ldapHostname}:${toString cfg.ldapPort}"; timeout = "5s"; start_tls = "false"; base_dn = cfg.dcdomain; # TODO: use user with less privilege and with lldap_password_manager group to be able to change passwords. user = "uid=admin,ou=people,${cfg.dcdomain}"; }; }; totp = { disable = "false"; issuer = fqdnWithPort; algorithm = "sha1"; digits = "6"; period = "30"; skew = "1"; secret_size = "32"; }; # Inspired from https://www.authelia.com/configuration/session/introduction/ and https://www.authelia.com/configuration/session/redis session = { name = "authelia_session"; cookies = [ { domain = if isNull cfg.port then cfg.domain else "${cfg.domain}:${toString cfg.port}"; authelia_url = "https://${cfg.subdomain}.${cfg.domain}"; } ]; same_site = "lax"; expiration = "1h"; inactivity = "5m"; remember_me = "1M"; redis = { host = config.services.redis.servers.authelia.unixSocket; port = 0; }; }; storage = { postgres = { address = "unix:///run/postgresql"; username = autheliaCfg.user; database = autheliaCfg.user; # Uses peer auth for local users, so we don't need a password. password = "test"; }; }; notifier = { filesystem = lib.mkIf (builtins.isString cfg.smtp) { filename = cfg.smtp; }; smtp = lib.mkIf (!(builtins.isString cfg.smtp)) { address = "${cfg.smtp.scheme}://${cfg.smtp.host}:${toString cfg.smtp.port}"; username = cfg.smtp.username; sender = "${cfg.smtp.from_name} <${cfg.smtp.from_address}>"; subject = "[Authelia] {title}"; startup_check_address = "test@authelia.com"; }; }; access_control = { default_policy = "deny"; networks = [ { name = "internal"; networks = [ "10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/18" ]; } ]; rules = [ { domain = fqdnWithPort; policy = "bypass"; resources = [ "^/api/.*" ]; } ] ++ cfg.rules; }; telemetry = { metrics = { enabled = true; address = "tcp://127.0.0.1:9959"; }; }; log.level = if cfg.debug then "debug" else "info"; } // { identity_providers.oidc = { claims_policies = { # This default claim should go away at some point. # https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/#restore-functionality-prior-to-claims-parameter default.id_token = [ "email" "preferred_username" "name" "groups" ]; } // cfg.extraOidcClaimsPolicies; scopes = cfg.extraOidcScopes; authorization_policies = cfg.extraOidcAuthorizationPolicies; }; } // lib.optionalAttrs (cfg.extraDefinitions != { }) { definitions = cfg.extraDefinitions; }; settingsFiles = [ "/var/lib/authelia-${fqdn}/oidc_clients.yaml" ]; }; systemd.services."authelia-${fqdn}".preStart = let mkCfg = clients: shb.replaceSecrets { userConfig = { identity_providers.oidc.clients = clients; }; resultPath = "/var/lib/authelia-${fqdn}/oidc_clients.yaml"; generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { }); }; in lib.mkBefore ( mkCfg cfg.oidcClients + '' ${pkgs.bash}/bin/bash -c '(while ! ${pkgs.netcat-openbsd}/bin/nc -z -v -w1 ${cfg.ldapHostname} ${toString cfg.ldapPort}; do echo "Waiting for port ${cfg.ldapHostname}:${toString cfg.ldapPort} to open..."; sleep 2; done); sleep 2' '' ); services.nginx.virtualHosts.${fqdn} = { forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; # Taken from https://github.com/authelia/authelia/issues/178 # TODO: merge with config from https://matwick.ca/authelia-nginx-sso/ locations."/".extraConfig = '' add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff; add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"; add_header X-Download-Options noopen; add_header X-Permitted-Cross-Domain-Policies none; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Uri $request_uri; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_cache_bypass $http_upgrade; proxy_pass http://127.0.0.1:9091; proxy_intercept_errors on; if ($request_method !~ ^(POST)$){ error_page 401 = /error/401; error_page 403 = /error/403; error_page 404 = /error/404; } ''; locations."/api/verify".extraConfig = '' add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff; add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"; add_header X-Download-Options noopen; add_header X-Permitted-Cross-Domain-Policies none; proxy_set_header Host $http_x_forwarded_host; proxy_pass http://127.0.0.1:9091; ''; }; # I would like this to live outside of the Authelia module. # This will require a reverse proxy contract. # Actually, not sure a full reverse proxy contract is needed. shb.mitmdump.instances."authelia-${fqdn}" = lib.mkIf cfg.debug { listenPort = 9091; upstreamPort = 9090; after = [ "authelia-${fqdn}.service" ]; enabledAddons = [ config.shb.mitmdump.addons.logger ]; extraArgs = [ "--set" "verbose_pattern=/api" ]; }; services.redis.servers.authelia = { enable = true; user = autheliaCfg.user; }; shb.postgresql.ensures = [ { username = autheliaCfg.user; database = autheliaCfg.user; } ]; services.prometheus.scrapeConfigs = [ { job_name = "authelia"; static_configs = [ { targets = [ "127.0.0.1:9959" ]; labels = { "hostname" = config.networking.hostName; "domain" = cfg.domain; }; } ]; } ]; systemd.targets."authelia-${fqdn}" = let services = [ "authelia-${fqdn}.service" ] ++ lib.optionals cfg.debug [ config.shb.mitmdump.instances."authelia-${fqdn}".serviceName ]; in { after = services; requires = services; wantedBy = [ "multi-user.target" ]; }; }; } ================================================ FILE: modules/blocks/backup/dashboard/Backups.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "target": { "limit": 100, "matchAny": false, "tags": [], "type": "dashboard" }, "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": 10, "links": [ { "asDropdown": false, "icon": "question", "includeVars": false, "keepTime": false, "tags": [], "targetBlank": false, "title": "Help", "tooltip": "", "type": "link", "url": "https://shb.skarabox.com/blocks-monitoring.html" } ], "panels": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "fixedColor": "green", "mode": "fixed" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "footer": { "reducers": [] }, "inspect": false }, "decimals": 0, "mappings": [], "noValue": "0", "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "green", "value": 80 } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "% failed" }, "properties": [ { "id": "unit", "value": "percentunit" }, { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } }, { "id": "color", "value": { "mode": "continuous-GrYlRd" } }, { "id": "max", "value": 1 } ] }, { "matcher": { "id": "byName", "options": "total" }, "properties": [ { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } }, { "id": "color", "value": { "mode": "thresholds" } }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "red", "value": 0 }, { "color": "transparent", "value": 1 } ] } } ] }, { "matcher": { "id": "byType", "options": "string" }, "properties": [ { "id": "custom.minWidth", "value": 150 } ] }, { "matcher": { "id": "byType", "options": "number" }, "properties": [ { "id": "custom.width", "value": 100 } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "id": 11, "options": { "cellHeight": "sm", "enablePagination": true, "frozenColumns": {}, "showHeader": true, "sortBy": [] }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "increase(systemd_unit_state{name=~\"[[job]].service\", state=\"activating\"}[7d])", "instant": true, "legendFormat": "__auto", "range": false, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "increase(systemd_unit_state{name=~\"[[job]].service\", state=\"failed\"}[7d])", "hide": false, "instant": true, "legendFormat": "__auto", "range": false, "refId": "B" } ], "title": "Backup Jobs in the Past Week", "transformations": [ { "id": "labelsToFields", "options": { "mode": "columns" } }, { "id": "merge", "options": {} }, { "id": "groupingToMatrix", "options": { "columnField": "state", "rowField": "name", "valueField": "Value" } }, { "id": "calculateField", "options": { "alias": "total", "binary": { "left": { "matcher": { "id": "byName", "options": "activating" } }, "operator": "+", "right": { "matcher": { "id": "byName", "options": "failed" } } }, "mode": "binary", "reduce": { "include": [ "activating", "failed" ], "reducer": "sum" } } }, { "id": "calculateField", "options": { "alias": "% failed", "binary": { "left": { "matcher": { "id": "byName", "options": "failed" } }, "operator": "/", "right": { "matcher": { "id": "byName", "options": "total" } } }, "mode": "binary", "reduce": { "reducer": "sum" } } }, { "id": "sortBy", "options": { "fields": {}, "sort": [ { "desc": true, "field": "total" } ] } }, { "id": "sortBy", "options": { "fields": {}, "sort": [ { "desc": true, "field": "failed" } ] } }, { "id": "organize", "options": { "excludeByName": {}, "includeByName": {}, "indexByName": {}, "renameByName": { "activating": "success", "name\\state": "Job" } } } ], "type": "table" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineStyle": { "fill": "solid" }, "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": 0 } ] }, "unit": "dateTimeFromNow" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, "id": 15, "options": { "legend": { "calcs": [ "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true, "sortBy": "Last *", "sortDesc": true, "width": 300 }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "editorMode": "code", "exemplar": false, "expr": "systemd_timer_next_trigger_seconds{name=~\"$job.timer\"} * 1000", "format": "time_series", "instant": false, "legendFormat": "{{name}}", "range": true, "refId": "A" } ], "title": "Schedule", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "fixed" }, "custom": { "axisPlacement": "auto", "fillOpacity": 70, "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineWidth": 0, "spanNulls": false }, "mappings": [ { "options": { "1": { "color": "green", "index": 0, "text": "Running" } }, "type": "value" } ], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "#EAB839", "value": 0 } ] } }, "overrides": [] }, "gridPos": { "h": 9, "w": 24, "x": 0, "y": 8 }, "id": 13, "options": { "alignValue": "left", "legend": { "displayMode": "list", "placement": "bottom", "showLegend": false }, "mergeValues": true, "rowHeight": 0.9, "showValue": "never", "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "max(systemd_unit_state{name=~\"$job.service\", state=\"activating\"} > 0 or on(name) label_replace(clamp(systemd_timer_last_trigger_seconds{name=~\"$job.timer\"} - (systemd_timer_last_trigger_seconds{name=~\"$job.timer\"} offset 1s) > 0, 0, 1), \"name\", \"$1.service\", \"name\", \"(.*).timer\")) by (name)", "format": "time_series", "instant": false, "key": "Q-e1d5c07a-8dcc-4f34-aa5c-cdebcbdda322-0", "legendFormat": "{{name}}", "range": true, "refId": "A" } ], "title": "Backups Jobs", "type": "state-timeline" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMax": 1, "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] }, "unit": "percentunit" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 }, "id": 1, "options": { "legend": { "calcs": [ "max", "mean" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "rate(node_disk_io_time_seconds_total{device=~\"sd.*\"}[2m])", "legendFormat": "{{device}}", "range": true, "refId": "A" } ], "title": "Disk IO Time", "type": "timeseries" }, { "description": "", "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 2, "w": 12, "x": 12, "y": 17 }, "id": 16, "options": { "code": { "language": "plaintext", "showLineNumbers": false, "showMiniMap": false }, "content": "If the log panel is empty, it may be because the amount of lines is too high.T ry filtering a few jobs first.", "mode": "markdown" }, "pluginVersion": "12.2.0", "title": "", "type": "text" }, { "datasource": { "default": false, "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 30, "w": 12, "x": 12, "y": 19 }, "id": 3, "options": { "dedupStrategy": "none", "enableInfiniteScrolling": false, "enableLogDetails": false, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "direction": "backward", "editorMode": "code", "expr": "{unit=~\"$job.*.service\"}", "queryType": "range", "refId": "A" } ], "title": "Logs - $job", "type": "logs" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "dashed" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "min": 0, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 } ] }, "unit": "bytes" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "rel" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "percentunit" }, { "id": "max", "value": 1.1 } ] }, { "matcher": { "id": "byFrameRefID", "options": "abs" }, "properties": [] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 25 }, "id": 8, "options": { "legend": { "calcs": [ "max", "mean" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "1 - node_filesystem_avail_bytes{device!~\"ramfs|tmpfs|none\", mountpoint=~\"$mountpoints\"} / node_filesystem_size_bytes{device!~\"ramfs|tmpfs|none\", mountpoint=~\"$mountpoints\"}", "hide": true, "legendFormat": "{{mountpoint}}", "range": true, "refId": "rel" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "node_filesystem_size_bytes{device!~\"ramfs|tmpfs|none\", mountpoint=~\"$mountpoints\"} - node_filesystem_avail_bytes{device!~\"ramfs|tmpfs|none\", mountpoint=~\"$mountpoints\"}", "hide": false, "instant": false, "legendFormat": "{{mountpoint}}", "range": true, "refId": "abs" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "node_filesystem_size_bytes{device!~\"ramfs|tmpfs|none\", mountpoint=~\"$mountpoints\"}", "hide": false, "instant": false, "legendFormat": "{{mountpoint}}", "range": true, "refId": "max" } ], "title": "Disk Usage", "transformations": [ { "id": "calculateField", "options": { "alias": "max", "binary": { "left": { "matcher": { "id": "byName", "options": "/srv/backup" } }, "operator": "*", "right": { "fixed": "1.1" } }, "mode": "binary", "reduce": { "reducer": "sum" }, "replaceFields": false, "unary": { "fieldName": "/srv/backup", "operator": "percent" } } }, { "id": "configFromData", "options": { "applyTo": { "id": "byFrameRefID", "options": "abs" }, "configRefId": "max", "mappings": [ { "fieldName": "/srv/backup", "handlerArguments": { "threshold": { "color": "red" } }, "handlerKey": "threshold1" }, { "fieldName": "max", "handlerKey": "max" } ] } }, { "id": "organize", "options": { "excludeByName": { "max": true }, "includeByName": {}, "indexByName": {}, "renameByName": {} } } ], "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMax": 1, "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] }, "unit": "percentunit" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 33 }, "id": 5, "options": { "legend": { "calcs": [ "max", "mean" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "avg(rate(node_cpu_seconds_total{mode!=\"idle\"}[2m])) by (instance, mode)", "legendFormat": "{{instance}} -- {{mode}}", "range": true, "refId": "A" } ], "title": "CPU", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMax": 1, "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": 3600000, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "dashed+area" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": 0 }, { "color": "transparent", "value": 0.05 } ] }, "unit": "bytes" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "perc" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "max", "value": 1 }, { "id": "unit", "value": "percentunit" }, { "id": "custom.hideFrom", "value": { "legend": false, "tooltip": false, "viz": false } } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 41 }, "id": 14, "options": { "legend": { "calcs": [ "max", "lastNotNull" ], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "builder", "expr": "avg by(instance) (node_memory_MemAvailable_bytes)", "fullMetaSearch": false, "hide": true, "includeNullMetadata": true, "legendFormat": "{{instance}} - total", "range": true, "refId": "available", "useBackend": false }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "builder", "expr": "avg by(instance) (node_memory_MemTotal_bytes)", "fullMetaSearch": false, "hide": true, "includeNullMetadata": true, "instant": false, "legendFormat": "{{instance}} - available", "range": true, "refId": "total", "useBackend": false }, { "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__" }, "expression": "$available / $total", "hide": false, "refId": "perc", "type": "math" } ], "title": "Memory", "type": "timeseries" } ], "preload": false, "refresh": "10s", "schemaVersion": 42, "tags": [], "templating": { "list": [ { "current": { "text": [ "All" ], "value": [ "$__all" ] }, "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "definition": "label_values(systemd_unit_state{name=~\".*backup.*\", name=~\".*.service\", name!~\".*restore.*\", name!~\".*pre.service\"},name)", "includeAll": true, "label": "Job", "multi": true, "name": "job", "options": [], "query": { "qryType": 1, "query": "label_values(systemd_unit_state{name=~\".*backup.*\", name=~\".*.service\", name!~\".*restore.*\", name!~\".*pre.service\"},name)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "/(?.*).service/", "sort": 1, "type": "query" }, { "current": { "text": [ "/srv/backup" ], "value": [ "/srv/backup" ] }, "definition": "label_values(node_filesystem_avail_bytes,mountpoint)", "includeAll": true, "multi": true, "name": "mountpoints", "options": [], "query": { "qryType": 1, "query": "label_values(node_filesystem_avail_bytes,mountpoint)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "type": "query" } ] }, "time": { "from": "now-3h", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Backups", "uid": "f05500d0-15ed-4719-b68d-fb898ca13cc8", "version": 62 } ================================================ FILE: modules/blocks/borgbackup/docs/default.md ================================================ # Borgbackup Block {#blocks-borgbackup} Defined in [`/modules/blocks/borgbackup.nix`](@REPO@/modules/blocks/borgbackup.nix). This block sets up a backup job using [BorgBackup][]. [borgbackup]: https://www.borgbackup.org/ ## Provider Contracts {#blocks-borgbackup-contract-provider} This block provides the following contracts: - [backup contract](contracts-backup.html) under the [`shb.borgbackup.instances`][instances] option. It is tested with [contract tests][backup contract tests]. - [database backup contract](contracts-databasebackup.html) under the [`shb.borgbackup.databases`][databases] option. It is tested with [contract tests][database backup contract tests]. [instances]: #blocks-borgbackup-options-shb.borgbackup.instances [databases]: #blocks-borgbackup-options-shb.borgbackup.databases [backup contract tests]: @REPO@/test/contracts/backup.nix [database backup contract tests]: @REPO@/test/contracts/databasebackup.nix As requested by those two contracts, when setting up a backup with BorgBackup, a backup Systemd service and a [restore script](#blocks-borgbackup-maintenance) are provided. ## Usage {#blocks-borgbackup-usage} The following examples assume usage of the [sops block][] to provide secrets although any blocks providing the [secrets contract][] works too. [sops block]: ./blocks-sops.html [secrets contract]: ./contracts-secrets.html ### One folder backed up manually {#blocks-borgbackup-usage-provider-manual} The following snippet shows how to configure the backup of 1 folder to 1 repository. We assume that the folder `/var/lib/myfolder` of the service `myservice` must be backed up. ```nix shb.borgbackup.instances."myservice" = { request = { user = "myservice"; sourceDirectories = [ "/var/lib/myfolder" ]; }; settings = { enable = true; passphrase.result = config.shb.sops.secret."passphrase".result; repository = { path = "/srv/backups/myservice"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; }; retention = { within = "1d"; hourly = 24; daily = 7; weekly = 4; monthly = 6; }; }; }; shb.sops.secret."passphrase".request = config.shb.borgbackup.instances."myservice".settings.passphrase.request; ``` ### One folder backed up with contract {#blocks-borgbackup-usage-provider-contract} With the same example as before but assuming the `myservice` service has a `myservice.backup` option that is a requester for the backup contract, the snippet above becomes: ```nix shb.borgbackup.instances."myservice" = { request = config.myservice.backup; settings = { enable = true; passphrase.result = config.shb.sops.secret."passphrase".result; repository = { path = "/srv/backups/myservice"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; }; retention = { within = "1d"; hourly = 24; daily = 7; weekly = 4; monthly = 6; }; }; }; shb.sops.secret."passphrase".request = config.shb.borgbackup.instances."myservice".settings.passphrase.request; ``` ### One folder backed up to S3 {#blocks-borgbackup-usage-provider-remote} Here we will only highlight the differences with the previous configuration. This assumes you have access to such a remote S3 store, for example by using [Backblaze](https://www.backblaze.com/). ```diff shb.test.backup.instances.myservice = { repository = { - path = "/srv/pool1/backups/myfolder"; + path = "s3:s3.us-west-000.backblazeb2.com/backups/myfolder"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; + extraSecrets = { + AWS_ACCESS_KEY_ID.source=""; + AWS_SECRET_ACCESS_KEY.source=""; + }; }; } ``` ### Multiple directories to multiple destinations {#blocks-borgbackup-usage-multiple} The following snippet shows how to configure backup of any number of folders to 3 repositories, each happening at different times to avoid I/O contention. We will also make sure to be able to re-use as much as the configuration as possible. A few assumptions: - 2 hard drive pools used for backup are mounted respectively on `/srv/pool1` and `/srv/pool2`. - You have a backblaze account. First, let's define a variable to hold all the repositories we want to back up to: ```nix repos = [ { path = "/srv/pool1/backups"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; } { path = "/srv/pool2/backups"; timerConfig = { OnCalendar = "08:00:00"; RandomizedDelaySec = "3h"; }; } { path = "s3:s3.us-west-000.backblazeb2.com/backups"; timerConfig = { OnCalendar = "16:00:00"; RandomizedDelaySec = "3h"; }; } ]; ``` Compared to the previous examples, we do not include the name of what we will back up in the repository paths. Now, let's define a function to create a backup configuration. It will take a list of repositories, a name identifying the backup and a list of folders to back up. ```nix backupcfg = repositories: name: sourceDirectories { enable = true; backend = "borgbackup"; keySopsFile = ../secrets/backup.yaml; repositories = builtins.map (r: { path = "${r.path}/${name}"; inherit (r) timerConfig; }) repositories; inherit sourceDirectories; retention = { within = "1d"; hourly = 24; daily = 7; weekly = 4; monthly = 6; }; environmentFile = true; }; ``` Now, we can define multiple backup jobs to backup different folders: ```nix shb.test.backup.instances.myfolder1 = backupcfg repos ["/var/lib/myfolder1"]; shb.test.backup.instances.myfolder2 = backupcfg repos ["/var/lib/myfolder2"]; ``` The difference between the above snippet and putting all the folders into one configuration (shown below) is the former splits the backups into sub-folders on the repositories. ```nix shb.test.backup.instances.all = backupcfg repos ["/var/lib/myfolder1" "/var/lib/myfolder2"]; ``` ## Monitoring {#blocks-borgbackup-monitoring} A generic dashboard for all backup solutions is provided. See [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backup) section in the monitoring chapter. ## Maintenance {#blocks-borgbackup-maintenance} One command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets. The restore script has all the secrets needed to access the repo, it will run `sudo` automatically and the user running it needs to have correct permissions for privilege escalation In the [multiple directories example](#blocks-borgbackup-usage-multiple) above, the following 6 helpers are provided in the `$PATH`: ```bash borgbackup-job-myfolder1_srv_pool1_backups borgbackup-job-myfolder1_srv_pool2_backups borgbackup-job-myfolder1_s3_s3.us-west-000.backblazeb2.com_backups borgbackup-job-myfolder2_srv_pool1_backups borgbackup-job-myfolder2_srv_pool2_backups borgbackup-job-myfolder2_s3_s3.us-west-000.backblazeb2.com_backups ``` Discovering those is easy thanks to tab-completion. One can then restore a backup from a given repository with: ```bash borgbackup-job-myfolder1_srv_pool1_backups restore latest ``` ### Troubleshooting {#blocks-borgbackup-maintenance-troubleshooting} In case something bad happens with a backup, the [official documentation](https://borgbackup.readthedocs.io/en/stable/077_troubleshooting.html) has a lot of tips. ## Tests {#blocks-borgbackup-tests} Specific integration tests are defined in [`/test/blocks/borgbackup.nix`](@REPO@/test/blocks/borgbackup.nix). ## Options Reference {#blocks-borgbackup-options} ```{=include=} options id-prefix: blocks-borgbackup-options- list-id: selfhostblocks-block-borgbackup-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/borgbackup.nix ================================================ { config, pkgs, lib, utils, shb, ... }: let cfg = config.shb.borgbackup; inherit (lib) concatStringsSep filterAttrs flatten literalExpression optionals listToAttrs mapAttrsToList mkOption mkMerge ; inherit (lib) mkIf nameValuePair optionalAttrs removePrefix ; inherit (lib.types) attrsOf int oneOf nonEmptyStr nullOr str submodule ; commonOptions = { name, prefix, config, ... }: { enable = lib.mkEnableOption '' SelfHostBlocks' BorgBackup block; A disabled instance will not backup data anymore but still provides the helper tool to restore snapshots ''; passphrase = lib.mkOption { description = "Encryption key for the backup repository."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.request.user; ownerText = "[shb.borgbackup.${prefix}..request.user](#blocks-borgbackup-options-shb.borgbackup.${prefix}._name_.request.user)"; restartUnits = [ (fullName name config.settings.repository) ]; restartUnitsText = "[ [shb.borgbackup.${prefix}..settings.repository](#blocks-borgbackup-options-shb.borgbackup.${prefix}._name_.settings.repository) ]"; }; }; }; repository = lib.mkOption { description = "Repository to send the backups to."; type = submodule { options = { path = mkOption { type = str; description = "Repository location"; }; secrets = mkOption { type = attrsOf shb.secretFileType; default = { }; description = '' Secrets needed to access the repository where the backups will be stored. See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets. ''; example = literalExpression '' { AWS_ACCESS_KEY_ID.source = ; AWS_SECRET_ACCESS_KEY.source = ; } ''; }; timerConfig = mkOption { type = attrsOf utils.systemdUtils.unitOptions.unitOption; default = { OnCalendar = "daily"; Persistent = true; }; description = "When to run the backup. See {manpage}`systemd.timer(5)` for details."; example = { OnCalendar = "00:05"; RandomizedDelaySec = "5h"; Persistent = true; }; }; }; }; }; retention = lib.mkOption { description = "Retention options. See {command}`borg help prune` for the available options."; type = attrsOf (oneOf [ int nonEmptyStr ]); default = { within = "1d"; hourly = 24; daily = 7; weekly = 4; monthly = 6; }; }; consistency = lib.mkOption { description = "Consistency frequency options."; type = lib.types.attrsOf lib.types.nonEmptyStr; default = { }; example = { repository = "2 weeks"; archives = "1 month"; }; }; limitUploadKiBs = mkOption { type = nullOr int; description = "Limit upload bandwidth to the given KiB/s amount."; default = null; example = 8000; }; stateDir = mkOption { type = nullOr lib.types.str; description = '' Override the directory in which {command}`borg` stores its configuration and cache. By default it uses the user's home directory but is some cases this can cause conflicts. ''; default = null; }; }; repoSlugName = name: builtins.replaceStrings [ "/" ":" ] [ "_" "_" ] (removePrefix "/" name); fullName = name: repository: "borgbackup-job-${name}_${repoSlugName repository.path}"; in { imports = [ ../../lib/module.nix ../blocks/monitoring.nix ]; options.shb.borgbackup = { enableDashboard = lib.mkEnableOption "the Backups SHB dashboard" // { default = true; }; instances = mkOption { description = "Files to backup following the [backup contract](./shb.contracts-backup.html)."; default = { }; type = attrsOf ( submodule ( { name, config, ... }: { options = shb.contracts.backup.mkProvider { settings = mkOption { description = '' Settings specific to the BorgBackup provider. ''; type = submodule { options = commonOptions { inherit name config; prefix = "instances"; }; }; }; resultCfg = { restoreScript = fullName name config.settings.repository; restoreScriptText = "${fullName "" { path = "path/to/repository"; }}"; backupService = "${fullName name config.settings.repository}.service"; backupServiceText = "${fullName "" { path = "path/to/repository"; }}.service"; }; }; } ) ); }; databases = mkOption { description = "Databases to backup following the [database backup contract](./shb.contracts-databasebackup.html)."; default = { }; type = attrsOf ( submodule ( { name, config, ... }: { options = shb.contracts.databasebackup.mkProvider { settings = mkOption { description = '' Settings specific to the BorgBackup provider. ''; type = submodule { options = commonOptions { inherit name config; prefix = "databases"; }; }; }; resultCfg = { restoreScript = fullName name config.settings.repository; restoreScriptText = "${fullName "" { path = "path/to/repository"; }}"; backupService = "${fullName name config.settings.repository}.service"; backupServiceText = "${fullName "" { path = "path/to/repository"; }}.service"; }; }; } ) ); }; borgServer = lib.mkOption { description = "Add borgbackup package to `environment.systemPackages` so external backups can use this server as a remote."; default = false; example = true; type = lib.types.bool; }; # Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23 performance = lib.mkOption { description = "Reduce performance impact of backup jobs."; default = { }; type = lib.types.submodule { options = { niceness = lib.mkOption { type = lib.types.ints.between (-20) 19; description = "nice priority adjustment, defaults to 15 for ~20% CPU time of normal-priority process"; default = 15; }; ioSchedulingClass = lib.mkOption { type = lib.types.enum [ "idle" "best-effort" "realtime" ]; description = "ionice scheduling class, defaults to best-effort IO."; default = "best-effort"; }; ioPriority = lib.mkOption { type = lib.types.nullOr (lib.types.ints.between 0 7); description = "ionice priority, defaults to 7 for lowest priority IO."; default = 7; }; }; }; }; }; config = lib.mkIf (cfg.instances != { } || cfg.databases != { }) ( let enabledInstances = filterAttrs (k: i: i.settings.enable) cfg.instances; enabledDatabases = filterAttrs (k: i: i.settings.enable) cfg.databases; in lib.mkMerge [ { environment.systemPackages = optionals (cfg.borgServer || enabledInstances != { } || enabledDatabases != { }) [ pkgs.borgbackup ]; } { services.borgbackup.jobs = let mkJob = name: instance: { "${name}_${repoSlugName instance.settings.repository.path}" = { inherit (instance.request) user; repo = instance.settings.repository.path; paths = instance.request.sourceDirectories; encryption.mode = "repokey-blake2"; # We do not set encryption.passphrase here, we set BORG_PASSPHRASE_FD further down. encryption.passCommand = "cat ${instance.settings.passphrase.result.path}"; doInit = true; failOnWarnings = true; stateDir = instance.settings.stateDir; persistentTimer = instance.settings.repository.timerConfig.Persistent or false; startAt = ""; # Some non-empty string value tricks the upstream module in creating the systemd timer. prune.keep = instance.settings.retention; preHook = concatStringsSep "\n" instance.request.hooks.beforeBackup; postHook = concatStringsSep "\n" instance.request.hooks.afterBackup; extraArgs = ( optionals (instance.settings.limitUploadKiBs != null) [ "--upload-ratelimit=${toString instance.settings.limitUploadKiBs}" ] ); exclude = instance.request.excludePatterns; }; }; in mkMerge (mapAttrsToList mkJob enabledInstances); } { services.borgbackup.jobs = let mkJob = name: instance: { "${name}_${repoSlugName instance.settings.repository.path}" = { inherit (instance.request) user; repo = instance.settings.repository.path; dumpCommand = lib.getExe ( pkgs.writeShellApplication { name = "dump-command"; text = instance.request.backupCmd; } ); encryption.mode = "repokey-blake2"; # We do not set encryption.passphrase here, we set BORG_PASSPHRASE_FD further down. encryption.passCommand = "cat ${instance.settings.passphrase.result.path}"; doInit = true; failOnWarnings = true; stateDir = instance.settings.stateDir; persistentTimer = instance.settings.repository.timerConfig.Persistent or false; startAt = ""; # Some non-empty list value that tricks upstream in creating the systemd timer. prune.keep = instance.settings.retention; extraArgs = ( optionals (instance.settings.limitUploadKiBs != null) [ "--upload-ratelimit=${toString instance.settings.limitUploadKiBs}" ] ); }; }; in mkMerge (mapAttrsToList mkJob enabledDatabases); } { systemd.timers = let mkTimer = name: instance: { ${fullName name instance.settings.repository} = { timerConfig = lib.mkForce instance.settings.repository.timerConfig; }; }; in mkMerge (mapAttrsToList mkTimer (enabledInstances // enabledDatabases)); } { systemd.services = let mkSettings = name: instance: let serviceName = fullName name instance.settings.repository; in { ${serviceName} = mkMerge [ { serviceConfig = { # Makes the systemd service wait for the backup to be done before changing state to inactive. Type = "oneshot"; Nice = lib.mkForce cfg.performance.niceness; IOSchedulingClass = lib.mkForce cfg.performance.ioSchedulingClass; IOSchedulingPriority = lib.mkForce cfg.performance.ioPriority; # BindReadOnlyPaths = instance.sourceDirectories; }; } (optionalAttrs (instance.settings.repository.secrets != { }) { serviceConfig.EnvironmentFile = [ "/run/secrets_borgbackup/${serviceName}" ]; after = [ "${serviceName}-pre.service" ]; requires = [ "${serviceName}-pre.service" ]; }) ]; "${serviceName}-pre" = mkIf (instance.settings.repository.secrets != { }) ( let script = shb.genConfigOutOfBandSystemd { config = instance.settings.repository.secrets; configLocation = "/run/secrets_borgbackup/${serviceName}"; generator = shb.toEnvVar; user = instance.request.user; }; in { script = script.preStart; serviceConfig.Type = "oneshot"; serviceConfig.LoadCredential = script.loadCredentials; } ); }; in mkMerge (flatten (mapAttrsToList mkSettings (enabledInstances // enabledDatabases))); } { systemd.services = let mkEnv = name: instance: nameValuePair "${fullName name instance.settings.repository}_restore_gen" { enable = true; wantedBy = [ "multi-user.target" ]; serviceConfig.Type = "oneshot"; script = ( shb.replaceSecrets { userConfig = instance.settings.repository.secrets // { BORG_PASSCOMMAND = ''"cat ${instance.settings.passphrase.result.path}"''; BORG_REPO = instance.settings.repository.path; }; resultPath = "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}"; generator = shb.toEnvVar; user = instance.request.user; } ); }; in listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases))); } { environment.systemPackages = let mkBorgBackupBinary = name: instance: pkgs.writeShellApplication { name = fullName name instance.settings.repository; text = '' usage() { echo "$0 restore latest" } if ! [ "$1" = "restore" ]; then usage exit 1 fi shift if ! [ "$1" = "latest" ]; then usage exit 1 fi shift sudocmd() { sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@" } set -a # shellcheck disable=SC1090 source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}") set +a archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)" echo "Will restore archive $archive" (cd / && sudocmd ${pkgs.borgbackup}/bin/borg extract "$BORG_REPO"::"$archive") ''; }; in flatten (mapAttrsToList mkBorgBackupBinary cfg.instances); } { environment.systemPackages = let mkBorgBackupBinary = name: instance: pkgs.writeShellApplication { name = fullName name instance.settings.repository; text = '' usage() { echo "$0 restore latest" } if ! [ "$1" = "restore" ]; then usage exit 1 fi shift if ! [ "$1" = "latest" ]; then usage exit 1 fi shift sudocmd() { sudo --preserve-env=BORG_REPO,BORG_PASSCOMMAND -u ${instance.request.user} "$@" } set -a # shellcheck disable=SC1090 source <(sudocmd cat "/run/secrets_borgbackup_env/${fullName name instance.settings.repository}") set +a archive="$(sudocmd borg list --short "$BORG_REPO" | tail -n 1)" echo "Will restore archive $archive" sudocmd sh -c "${pkgs.borgbackup}/bin/borg extract $BORG_REPO::$archive --stdout | ${instance.request.restoreCmd}" ''; }; in flatten (mapAttrsToList mkBorgBackupBinary cfg.databases); } (lib.mkIf (cfg.enableDashboard && (cfg.instances != { } || cfg.databases != { })) { shb.monitoring.dashboards = [ ./backup/dashboard/Backups.json ]; }) ] ); } ================================================ FILE: modules/blocks/davfs.nix ================================================ { config, lib, ... }: let cfg = config.shb.davfs; in { options.shb.davfs = { mounts = lib.mkOption { description = "List of mounts."; default = [ ]; type = lib.types.listOf ( lib.types.submodule { options = { remoteUrl = lib.mkOption { type = lib.types.str; description = "Webdav endpoint to connect to."; example = "https://my.domain.com/dav"; }; mountPoint = lib.mkOption { type = lib.types.str; description = "Mount point to mount the webdav endpoint on."; example = "/mnt"; }; username = lib.mkOption { type = lib.types.str; description = "Username to connect to the webdav endpoint."; }; passwordFile = lib.mkOption { type = lib.types.str; description = "Password to connect to the webdav endpoint."; }; uid = lib.mkOption { type = lib.types.nullOr lib.types.int; description = "User owner of the mount point."; example = 1000; default = null; }; gid = lib.mkOption { type = lib.types.nullOr lib.types.int; description = "Group owner of the mount point."; example = 1000; default = null; }; fileMode = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "File creation mode"; example = "0664"; default = null; }; directoryMode = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "Directory creation mode"; example = "2775"; default = null; }; automount = lib.mkOption { type = lib.types.bool; description = "Create a systemd automount unit"; default = true; }; }; } ); }; }; config = { services.davfs2.enable = builtins.length cfg.mounts > 0; systemd.mounts = let mkMountCfg = c: { enable = true; description = "Webdav mount point"; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; what = c.remoteUrl; where = c.mountPoint; options = lib.concatStringsSep "," ( (lib.optional (!(isNull c.uid)) "uid=${toString c.uid}") ++ (lib.optional (!(isNull c.gid)) "gid=${toString c.gid}") ++ (lib.optional (!(isNull c.fileMode)) "file_mode=${toString c.fileMode}") ++ (lib.optional (!(isNull c.directoryMode)) "dir_mode=${toString c.directoryMode}") ); type = "davfs"; mountConfig.TimeoutSec = 15; }; in map mkMountCfg cfg.mounts; }; } ================================================ FILE: modules/blocks/hardcodedsecret.nix ================================================ { config, lib, pkgs, shb, ... }: let cfg = config.shb.hardcodedsecret; inherit (lib) mapAttrs' mkOption nameValuePair; inherit (lib.types) attrsOf nullOr str submodule ; inherit (pkgs) writeText; in { imports = [ ../../lib/module.nix ]; options.shb.hardcodedsecret = mkOption { default = { }; description = '' Hardcoded secrets. These should only be used in tests. ''; example = lib.literalExpression '' { mySecret = { request = { user = "me"; mode = "0400"; restartUnits = [ "myservice.service" ]; }; settings.content = "My Secret"; }; } ''; type = attrsOf ( submodule ( { name, ... }: { options = shb.contracts.secret.mkProvider { settings = mkOption { description = '' Settings specific to the hardcoded secret module. Give either `content` or `source`. ''; type = submodule { options = { content = mkOption { type = nullOr str; description = '' Content of the secret as a string. This will be stored in the nix store and should only be used for testing or maybe in dev. ''; default = null; }; source = mkOption { type = nullOr str; description = '' Source of the content of the secret as a path in the nix store. ''; default = null; }; }; }; }; resultCfg = { path = "/run/hardcodedsecrets/hardcodedsecret_${name}"; }; }; } ) ); }; config = { system.activationScripts = mapAttrs' ( n: cfg': let source = if cfg'.settings.source != null then cfg'.settings.source else writeText "hardcodedsecret_${n}_content" cfg'.settings.content; in nameValuePair "hardcodedsecret_${n}" '' mkdir -p "$(dirname "${cfg'.result.path}")" touch "${cfg'.result.path}" chmod ${cfg'.request.mode} "${cfg'.result.path}" chown ${cfg'.request.owner}:${cfg'.request.group} "${cfg'.result.path}" cp ${source} "${cfg'.result.path}" '' ) cfg; }; } ================================================ FILE: modules/blocks/lldap/docs/default.md ================================================ # LLDAP Block {#blocks-lldap} Defined in [`/modules/blocks/lldap.nix`](@REPO@/modules/blocks/lldap.nix). This block sets up an [LLDAP][] service for user and group management across services. [LLDAP]: https://github.com/lldap/lldap ## Features {#blocks-lldap-features} - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#blocks-lldap-usage-applicationdashboard) ## Usage {#blocks-lldap-usage} ### Initial Configuration {#blocks-lldap-usage-configuration} ```nix shb.lldap = { enable = true; subdomain = "ldap"; domain = "example.com"; dcdomain = "dc=example,dc=com"; ldapPort = 3890; webUIListenPort = 17170; jwtSecret.result = config.shb.sops.secret."lldap/jwt_secret".result; ldapUserPassword.result = config.shb.sops.secret."lldap/user_password".result; }; shb.sops.secret."lldap/jwt_secret".request = config.shb.lldap.jwtSecret.request; shb.sops.secret."lldap/user_password".request = config.shb.lldap.ldapUserPassword.request; ``` This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. ### SSL {#blocks-lldap-usage-ssl} Using SSL is an important security practice, like always. Using the [SSL block][], the configuration to add to the one above is: [SSL block]: blocks-ssl.html ```nix shb.certs.certs.letsencrypt.${domain}.extraDomains = [ "${config.shb.lldap.subdomain}.${config.shb.lldap.domain}" ]; shb.lldap.ssl = config.shb.certs.certs.letsencrypt.${config.shb.lldap.domain}; ``` ### Restrict Access By IP {#blocks-lldap-usage-restrict-access-by-ip} For added security, you can restrict access to the LLDAP UI by adding the following line: ```nix shb.lldap.restrictAccessIPRange = "192.168.50.0/24"; ``` ### Application Dashboard {#blocks-lldap-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#blocks-lldap-options-shb.lldap.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Admin.services.LLDAP = { sortOrder = 2; dashboard.request = config.shb.lldap.dashboard.request; }; } ``` ## Manage Groups {#blocks-lldap-manage-groups} The following snippet will create group named "family" if it does not exist yet. Also, all other groups will be deleted and only the "family" group will remain. Note that the `lldap_admin`, `lldap_password_manager` and `lldap_strict_readonly` groups, which are internal to LLDAP, will always exist. If you want existing groups not declared in the `shb.lldap.ensureGroups` to be deleted, set [`shb.lldap.enforceGroups`](#blocks-lldap-options-shb.lldap.enforceGroups) to `true`. ```nix { shb.lldap.ensureGroups = { family = {}; }; } ``` Changing the configuration to the following will add a new group "friends": ```nix { shb.lldap.ensureGroups = { family = {}; friends = {}; }; } ``` Switching back the configuration to the previous one will delete the group "friends": ```nix { shb.lldap.ensureGroups = { family = {}; }; } ``` Custom fields can be added to groups as long as they are added to the `ensureGroupFields` field: ```nix shb.lldap = { ensureGroupFields = { mygroupattribute = { attributeType = "STRING"; }; }; ensureGroups = { family = { mygroupattribute = "Managed by NixOS"; }; }; }; ``` ## Manage Users {#blocks-lldap-manage-users} The following snippet creates a user and makes it a member of the "family" group. Note the following behavior: - New users will be created following the `shb.lldap.ensureUsers` option. - Existing users will be updated, their password included, if they are mentioned in the `shb.lldap.ensureUsers` option. - Existing users not declared in the `shb.lldap.ensureUsers` will be left as-is. - User memberships to groups not declared in their respective `shb.lldap.ensureUsers..groups`. If you want existing users not declared in the `shb.lldap.ensureUsers` to be deleted, set [`shb.lldap.enforceUsers`](#blocks-lldap-options-shb.lldap.enforceUsers) to `true`. If you want memberships to groups not declared in the respective `shb.lldap.ensureUsers..groups` option to be deleted, set [`shb.lldap.enforceUserMemberships`](#blocks-lldap-options-shb.lldap.enforceUserMemberships) `true`. ```nix { shb.lldap.ensureUsers = { dad = { email = "dad@example.com"; displayName = "Dad"; firstName = "First Name"; lastName = "Last Name"; groups = [ "family" ]; password.result = config.shb.sops.secret."dad".result; }; }; shb.sops.secret."dad".request = config.shb.lldap.ensureUsers.dad.password.request; } ``` The password field assumes usage of the [sops block][] to provide secrets although any blocks providing the [secrets contract][] works too. [sops block]: blocks-sops.html [secrets contract]: contracts-secrets.html The user is still editable through the UI. That being said, any change will be overwritten next time the configuration is applied. ## Troubleshooting {#blocks-lldap-troubleshooting} To increase logging verbosity and see the trace of the GraphQL queries, add: ```nix shb.lldap.debug = true; ``` Note that verbosity is truly verbose here so you will want to revert this at some point. To see the logs, then run `journalctl -u lldap.service`. Setting the `debug` option to `true` will also add an [shb.mitmdump][] instance in front of the LLDAP [web UI port](#blocks-lldap-options-shb.lldap.webUIListenPort) which prints all requests and responses headers and body to the systemd service `mitmdump-lldap.service`. Note the you won't see the query done using something like `ldapsearch` since those go through the [`LDAP` port](#blocks-lldap-options-shb.lldap.ldapPort). [shb.mitmdump]: ./blocks-mitmdump.html ## Tests {#blocks-lldap-tests} Specific integration tests are defined in [`/test/blocks/lldap.nix`](@REPO@/test/blocks/lldap.nix). ## Options Reference {#blocks-lldap-options} ```{=include=} options id-prefix: blocks-lldap-options- list-id: selfhostblocks-block-lldap-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/lldap.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.lldap; fqdn = "${cfg.subdomain}.${cfg.domain}"; inherit (lib) mkOption types; ensureFormat = pkgs.formats.json { }; ensureFieldsOptions = name: { name = mkOption { type = types.str; description = "Name of the field."; default = name; }; attributeType = mkOption { type = types.enum [ "STRING" "INTEGER" "JPEG" "DATE_TIME" ]; description = "Attribute type."; }; isEditable = mkOption { type = types.bool; description = "Is field editable."; default = true; }; isList = mkOption { type = types.bool; description = "Is field a list."; default = false; }; isVisible = mkOption { type = types.bool; description = "Is field visible in UI."; default = true; }; }; in { imports = [ ../../lib/module.nix ./mitmdump.nix (lib.mkRenamedOptionModule [ "shb" "ldap" ] [ "shb" "lldap" ]) ]; options.shb.lldap = { enable = lib.mkEnableOption "the LDAP service"; dcdomain = lib.mkOption { type = lib.types.str; description = "dc domain to serve."; example = "dc=mydomain,dc=com"; }; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which the LDAP service will be served."; example = "grafana"; }; domain = lib.mkOption { type = lib.types.str; description = "Domain under which the LDAP service will be served."; example = "mydomain.com"; }; ldapPort = lib.mkOption { type = lib.types.port; description = "Port on which the server listens for the LDAP protocol."; default = 3890; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; webUIListenPort = lib.mkOption { type = lib.types.port; description = "Port on which the web UI is exposed."; default = 17170; }; ldapUserPassword = lib.mkOption { description = "LDAP admin user secret. Must be >= 8 characters."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "lldap"; group = "lldap"; restartUnits = [ "lldap.service" ]; }; }; }; jwtSecret = lib.mkOption { description = "JWT secret."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "lldap"; group = "lldap"; restartUnits = [ "lldap.service" ]; }; }; }; restrictAccessIPRange = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "Set a local network range to restrict access to the UI to only those IPs."; example = "192.168.1.1/24"; default = null; }; debug = lib.mkOption { description = "Enable debug logging."; type = lib.types.bool; default = false; }; mount = lib.mkOption { type = shb.contracts.mount; description = '' Mount configuration. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."ldap" = { poolName = "root"; } // config.shb.lldap.mount; ``` ''; readOnly = true; default = { path = "/var/lib/lldap"; }; }; backup = lib.mkOption { description = '' Backup configuration. ''; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { # TODO: is there a workaround that avoid needing to use root? # root because otherwise we cannot access the private StateDiretory user = "root"; # /private because the systemd service uses DynamicUser=true sourceDirectories = [ "/var/lib/private/lldap" ]; }; }; }; ensureUsers = mkOption { description = '' Create the users defined here on service startup. If `enforceUsers` option is `true`, the groups users belong to must be present in the `ensureGroups` option. Non-default options must be added to the `ensureGroupFields` option. ''; default = { }; type = types.attrsOf ( types.submodule ( { name, ... }: { freeformType = ensureFormat.type; options = { id = mkOption { type = types.str; description = "Username."; default = name; }; email = mkOption { type = types.str; description = "Email."; }; password = mkOption { description = "Password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "lldap"; group = "lldap"; restartUnits = [ "lldap.service" ]; }; }; }; displayName = mkOption { type = types.nullOr types.str; default = null; description = "Display name."; }; firstName = mkOption { type = types.nullOr types.str; default = null; description = "First name."; }; lastName = mkOption { type = types.nullOr types.str; default = null; description = "Last name."; }; avatar_file = mkOption { type = types.nullOr types.str; default = null; description = "Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)"; }; avatar_url = mkOption { type = types.nullOr types.str; default = null; description = "Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)"; }; gravatar_avatar = mkOption { type = types.nullOr types.str; default = null; description = "Get avatar from Gravatar using the email."; }; weser_avatar = mkOption { type = types.nullOr types.str; default = null; description = "Convert avatar retrieved by gravatar or the URL."; }; groups = mkOption { type = types.listOf types.str; default = [ ]; description = "Groups the user would be a member of (all the groups must be specified in group config files)."; }; }; } ) ); }; ensureGroups = mkOption { description = '' Create the groups defined here on service startup. Non-default options must be added to the `ensureGroupFields` option. ''; default = { }; type = types.attrsOf ( types.submodule ( { name, ... }: { freeformType = ensureFormat.type; options = { name = mkOption { type = types.str; description = "Name of the group."; default = name; }; }; } ) ); }; ensureUserFields = mkOption { description = "Extra fields for users"; default = { }; type = types.attrsOf ( types.submodule ( { name, ... }: { options = ensureFieldsOptions name; } ) ); }; ensureGroupFields = mkOption { description = "Extra fields for groups"; default = { }; type = types.attrsOf ( types.submodule ( { name, ... }: { options = ensureFieldsOptions name; } ) ); }; enforceUsers = mkOption { description = "Delete users not set declaratively."; type = types.bool; default = false; }; enforceUserMemberships = mkOption { description = "Remove users from groups not set declaratively."; type = types.bool; default = false; }; enforceGroups = mkOption { description = "Remove groups not set declaratively."; type = types.bool; default = false; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.lldap.subdomain}.\${config.shb.lldap.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.webUIListenPort}"; }; }; }; }; config = lib.mkIf cfg.enable { services.nginx = { enable = true; virtualHosts.${fqdn} = { forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; locations."/" = { extraConfig = '' proxy_set_header Host $host; '' + ( if isNull cfg.restrictAccessIPRange then "" else '' allow ${cfg.restrictAccessIPRange}; deny all; '' ); proxyPass = "http://${toString config.services.lldap.settings.http_host}:${toString config.shb.lldap.webUIListenPort}/"; }; }; }; users.users.lldap = { name = "lldap"; group = "lldap"; isSystemUser = true; }; users.groups.lldap = { }; services.lldap = { enable = true; inherit (cfg) enforceUsers enforceUserMemberships enforceGroups; environment = { RUST_LOG = lib.mkIf cfg.debug "debug"; }; settings = { http_url = "https://${fqdn}"; http_host = "127.0.0.1"; http_port = if !cfg.debug then cfg.webUIListenPort else cfg.webUIListenPort + 1; ldap_host = "127.0.0.1"; ldap_port = cfg.ldapPort; # Would be great to be able to inspect this but it requires tcpdump instead of mitmproxy. ldap_base_dn = cfg.dcdomain; ldap_user_pass_file = toString cfg.ldapUserPassword.result.path; force_ldap_user_pass_reset = "always"; jwt_secret_file = toString cfg.jwtSecret.result.path; verbose = cfg.debug; }; inherit (cfg) ensureGroups ensureUserFields ensureGroupFields; ensureUsers = lib.mapAttrs ( n: v: (lib.removeAttrs v [ "password" ]) // { "password_file" = toString v.password.result.path; } ) cfg.ensureUsers; }; shb.mitmdump.instances."lldap-web" = lib.mkIf cfg.debug { listenPort = config.shb.lldap.webUIListenPort; upstreamPort = config.shb.lldap.webUIListenPort + 1; after = [ "lldap.service" ]; enabledAddons = [ config.shb.mitmdump.addons.logger ]; extraArgs = [ "--set" "verbose_pattern=/api" ]; }; }; } ================================================ FILE: modules/blocks/mitmdump/docs/default.md ================================================ # Mitmdump Block {#blocks-mitmdump} Defined in [`/modules/blocks/mitmdump.nix`](@REPO@/modules/blocks/mitmdump.nix). This block sets up an [Mitmdump][] service in [reverse proxy][] mode. In other words, you can put this block between a client and a server to inspect all the network traffic. [Mitmdump]: https://plattner.me/mp-docs/#mitmdump [reverse proxy]: https://plattner.me/mp-docs/concepts-modes/#reverse-proxy Multiple instances of mitmdump all listening on different ports and proxying to different upstream servers can be created. The systemd service is made so it is started only when the mitmdump instance has started listening on the expected port. Also, addons can be enabled with the `enabledAddons` option. ## Usage {#blocks-mitmdump-usage} Put mitmdump in front of a HTTP server listening on port 8000 on the same machine: ```nix shb.mitmdump.instances."my-instance" = { listenPort = 8001; upstreamHost = "http://127.0.0.1"; upstreamPort = 8000; after = [ "server.service" ]; }; ``` `upstreamHost` has its default value here and can be left out. Put mitmdump in front of a HTTP server listening on port 8000 on another machine: ```nix shb.mitmdump.instances."my-instance" = { listenPort = 8001; upstreamHost = "http://otherhost"; upstreamPort = 8000; after = [ "server.service" ]; }; ``` ### Handle Upstream TLS {#blocks-mitmdump-usage-https} Replace `http` with `https` if the server expects an HTTPS connection. ### Accept Connections from Anywhere {#blocks-mitmdump-usage-anywhere} By default, `mitmdump` is configured to listen only for connections from localhost. Add `listenHost=0.0.0.0` to make `mitmdump` accept connections from anywhere. ### Extra Logging {#blocks-mitmdump-usage-logging} To print request and response bodies and more, increase the logging with: ```nix extraArgs = [ "--set" "flow_detail=3" "--set" "content_view_lines_cutoff=2000" ]; ``` The default `flow_details` is 1. See the [manual][] for more explanations on the option. [manual]: (https://docs.mitmproxy.org/stable/concepts/options/#flow_detail) This will change the verbosity for all requests and responses. If you need more fine grained logging, configure instead the [Logger Addon][]. [Logger Addon]: #blocks-mitmdump-addons-logger ## Addons {#blocks-mitmdump-addons} All provided addons can be found under the `shb.mitmproxy.addons` option. To enable one for an instance, add it to the `enabledAddons` option. For example: ```nix shb.mitmdump.instances."my-instance" = { enabledAddons = [ config.shb.mitmdump.addons.logger ] } ``` ### Fine Grained Logger {#blocks-mitmdump-addons-logger} The Fine Grained Logger addon is found under `shb.mitmproxy.addons.logger`. Enabling this addon will add the `mitmdump` option `verbose_pattern` which takes a regex and if it matches, prints the request and response headers and body. If it does not match, it will just print the response status. For example, with the `extraArgs`: ```nix extraArgs = [ "--set" "verbose_pattern=/verbose" ]; ``` A `GET` request to `/notverbose` will print something similar to: ``` mitmdump[972]: 127.0.0.1:53586: GET http://127.0.0.1:8000/notverbose HTTP/1.1 mitmdump[972]: << HTTP/1.0 200 OK 16b ``` While a `GET` request to `/verbose` will print something similar to: ``` mitmdump[972]: [22:42:58.840] mitmdump[972]: RequestHeaders: mitmdump[972]: Host: 127.0.0.1:8000 mitmdump[972]: User-Agent: curl/8.14.1 mitmdump[972]: Accept: */* mitmdump[972]: RequestBody: mitmdump[972]: Status: 200 mitmdump[972]: ResponseHeaders: mitmdump[972]: Server: BaseHTTP/0.6 Python/3.13.4 mitmdump[972]: Date: Sun, 03 Aug 2025 22:42:58 GMT mitmdump[972]: Content-Type: text/plain mitmdump[972]: Content-Length: 13 mitmdump[972]: ResponseBody: test2/verbose mitmdump[972]: 127.0.0.1:53602: GET http://127.0.0.1:8000/verbose HTTP/1.1 mitmdump[972]: << HTTP/1.0 200 OK 13b ``` ## Example {#blocks-mitmdump-example} Let's assume a server is listening on port 8000 which responds a plain text response `test1` and its related systemd service is named `test1.service`. Sorry, creative naming is not my forte. Let's put an mitmdump instance in front of it, like so: ```nix shb.mitmdump.instances."test1" = { listenPort = 8001; upstreamPort = 8000; after = [ "test1.service" ]; extraArgs = [ "--set" "flow_detail=3" "--set" "content_view_lines_cutoff=2000" ]; }; ``` This creates an `mitmdump-test1.service` systemd service. We can then use `journalctl -u mitmdump-test1.service` to see the output. If we make a `curl` request to it: `curl -v http://127.0.0.1:8001`, we will get the following output: ``` mitmdump-test1[971]: 127.0.0.1:40878: GET http://127.0.0.1:8000/ HTTP/1.1 mitmdump-test1[971]: Host: 127.0.0.1:8000 mitmdump-test1[971]: User-Agent: curl/8.14.1 mitmdump-test1[971]: Accept: */* mitmdump-test1[971]: << HTTP/1.0 200 OK 5b mitmdump-test1[971]: Server: BaseHTTP/0.6 Python/3.13.4 mitmdump-test1[971]: Date: Thu, 31 Jul 2025 20:55:16 GMT mitmdump-test1[971]: Content-Type: text/plain mitmdump-test1[971]: Content-Length: 5 mitmdump-test1[971]: test1 ``` ## Tests {#blocks-mitmdump-tests} Specific integration tests are defined in [`/test/blocks/mitmdump.nix`](@REPO@/test/blocks/mitmdump.nix). ## Options Reference {#blocks-mitmdump-options} ```{=include=} options id-prefix: blocks-mitmdump-options- list-id: selfhostblocks-block-mitmdump-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/mitmdump.nix ================================================ { config, lib, pkgs, ... }: let inherit (lib) mapAttrs' mkOption nameValuePair types ; inherit (types) attrsOf listOf port submodule str ; cfg = config.shb.mitmdump; mitmdumpScript = pkgs.writers.writePython3Bin "mitmdump" { libraries = let p = pkgs.python3Packages; in [ p.systemd-python p.mitmproxy ]; flakeIgnore = [ "E501" ]; } '' from systemd.daemon import notify import argparse import logging import os import subprocess import socket import sys import time logging.basicConfig(level=logging.INFO, format='%(message)s') def wait_for_port(host, port, timeout=10): deadline = time.time() + timeout while time.time() < deadline: try: with socket.create_connection((host, port), timeout=0.5): return True except Exception: time.sleep(0.1) return False def flatten(xss): return [x for xs in xss for x in xs] parser = argparse.ArgumentParser() parser.add_argument("--listen_host", default="127.0.0.1", help="Host mitmdump will listen on") parser.add_argument("--listen_port", required=True, help="Port mitmdump will listen on") parser.add_argument("--upstream_host", default="http://127.0.0.1", help="Host mitmdump will connect to for upstream. Example: http://127.0.0.1 or https://otherhost") parser.add_argument("--upstream_port", required=True, help="Port mitmdump will connect to for upstream") args, rest = parser.parse_known_args() MITMDUMP_BIN = os.environ.get("MITMDUMP_BIN") if MITMDUMP_BIN is None: raise Exception("MITMDUMP_BIN env var must be set to the path of the mitmdump binary") logging.info(f"Waiting for upstream address '{args.upstream_host}:{args.upstream_port}' to be up.") wait_for_port(args.upstream_host, args.upstream_port, timeout=10) logging.info(f"Upstream address '{args.upstream_host}:{args.upstream_port}' is up.") proc = subprocess.Popen( [ MITMDUMP_BIN, "--listen-host", args.listen_host, "-p", args.listen_port, "--mode", f"reverse:{args.upstream_host}:{args.upstream_port}", ] + rest, stdout=sys.stdout, stderr=sys.stderr, ) logging.info(f"Waiting for mitmdump instance to start on port {args.listen_port}.") if wait_for_port("127.0.0.1", args.listen_port, timeout=10): logging.info(f"Mitmdump is started on port {args.listen_port}.") notify("READY=1") else: proc.terminate() exit(1) proc.wait() ''; logger = toString ( pkgs.writers.writeText "loggerAddon.py" '' import logging from collections.abc import Sequence from mitmproxy import ctx, http import re logger = logging.getLogger(__name__) class RegexLogger: def __init__(self): self.verbose_patterns = None def load(self, loader): loader.add_option( name="verbose_pattern", typespec=Sequence[str], default=[], help="Regex patterns for verbose logging", ) def response(self, flow: http.HTTPFlow): if self.verbose_patterns is None: self.verbose_patterns = [re.compile(p) for p in ctx.options.verbose_pattern] matched = any(p.search(flow.request.path) for p in self.verbose_patterns) if matched: logger.info(format_flow(flow)) def format_flow(flow: http.HTTPFlow) -> str: return ( "\n" "RequestHeaders:\n" f" {format_headers(flow.request.headers.items())}\n" f"RequestBody: {flow.request.get_text()}\n" f"Status: {flow.response.data.status_code}\n" "ResponseHeaders:\n" f" {format_headers(flow.response.headers.items())}\n" f"ResponseBody: {flow.response.get_text()}\n" ) def format_headers(headers) -> str: return "\n ".join(k + ": " + v for k, v in headers) addons = [RegexLogger()] '' ); in { options.shb.mitmdump = { addons = mkOption { type = attrsOf str; default = [ ]; description = '' Addons available to the be added to the mitmdump instance. To enabled them, add them to the `enabledAddons` option. ''; }; instances = mkOption { default = { }; description = "Mitmdump instance."; type = attrsOf ( submodule ( { name, ... }: { options = { package = lib.mkPackageOption pkgs "mitmproxy" { }; serviceName = mkOption { type = str; description = '' Name of the mitmdump system service. ''; default = "mitmdump-${name}.service"; readOnly = true; }; listenHost = mkOption { type = str; default = "127.0.0.1"; description = '' Host the mitmdump instance will connect on. ''; }; listenPort = mkOption { type = port; description = '' Port the mitmdump instance will listen on. The upstream port from the client's perspective. ''; }; upstreamHost = mkOption { type = str; default = "http://127.0.0.1"; description = '' Host the mitmdump instance will connect to. If only an IP or domain is provided, mitmdump will default to connect using HTTPS. If this is not wanted, prefix the IP or domain with the 'http://' protocol. ''; }; upstreamPort = mkOption { type = port; description = '' Port the mitmdump instance will connect to. The port the server is listening on. ''; }; after = mkOption { type = listOf str; default = [ ]; description = '' Systemd services that must be started before this mitmdump proxy instance. You are guaranteed the mitmdump is listening on the `listenPort` when its systemd service has started. ''; }; enabledAddons = mkOption { type = listOf str; default = [ ]; description = '' Addons to enable on this mitmdump instance. ''; example = lib.literalExpression "[ config.shb.mitmdump.addons.logger ]"; }; extraArgs = mkOption { type = listOf str; default = [ ]; description = '' Extra arguments to pass to the mitmdump instance. See upstream [manual](https://docs.mitmproxy.org/stable/concepts/options/#flow_detail) for all possible options. ''; example = lib.literalExpression ''[ "--set" "verbose_pattern=/api" ]''; }; }; } ) ); }; }; config = { systemd.services = mapAttrs' ( name: cfg': nameValuePair "mitmdump-${name}" { environment = { "HOME" = "/var/lib/private/mitmdump-${name}"; "MITMDUMP_BIN" = "${cfg'.package}/bin/mitmdump"; }; serviceConfig = { Type = "notify"; Restart = "on-failure"; StandardOutput = "journal"; StandardError = "journal"; DynamicUser = true; WorkingDirectory = "/var/lib/mitmdump-${name}"; StateDirectory = "mitmdump-${name}"; ExecStart = let addons = lib.concatMapStringsSep " " (addon: "-s ${addon}") cfg'.enabledAddons; extraArgs = lib.concatStringsSep " " cfg'.extraArgs; in "${lib.getExe mitmdumpScript} --listen_host ${cfg'.listenHost} --listen_port ${toString cfg'.listenPort} --upstream_host ${cfg'.upstreamHost} --upstream_port ${toString cfg'.upstreamPort} ${addons} ${extraArgs}"; }; requires = cfg'.after; after = cfg'.after; wantedBy = [ "multi-user.target" ]; } ) cfg.instances; shb.mitmdump.addons = { inherit logger; }; }; } ================================================ FILE: modules/blocks/monitoring/dashboards/Errors.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": 8, "links": [], "panels": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "light-red", "value": null }, { "color": "transparent", "value": 0.99 } ] }, "unit": "reqps" }, "overrides": [] }, "gridPos": { "h": 7, "w": 24, "x": 0, "y": 0 }, "id": 12, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "legend": { "calcs": [ "mean", "max" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "sum by(server_name) (rate({unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | server_name =~ \"[[server_name]].*\" [$__auto]))", "legendFormat": "{{server_name}}", "queryType": "range", "refId": "A" } ], "title": "Rate of Requests", "transformations": [ { "id": "extractFields", "options": { "replace": true, "source": "Line" } }, { "id": "organize", "options": { "excludeByName": {}, "indexByName": { "body_bytes_sent": 9, "bytes_sent": 8, "gzip_ration": 11, "post": 12, "referrer": 10, "remote_addr": 3, "remote_user": 6, "request": 4, "request_length": 7, "request_time": 15, "server_name": 2, "status": 1, "time_local": 0, "upstream_addr": 13, "upstream_connect_time": 17, "upstream_header_time": 18, "upstream_response_time": 16, "upstream_status": 14, "user_agent": 5 }, "renameByName": {} } } ], "type": "timeseries" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMin": 0.5, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "dashed+area" } }, "mappings": [], "max": 1.01, "thresholds": { "mode": "absolute", "steps": [ { "color": "light-red", "value": null }, { "color": "transparent", "value": 0.99 } ] }, "unit": "percentunit" }, "overrides": [] }, "gridPos": { "h": 7, "w": 12, "x": 0, "y": 7 }, "id": 9, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "legend": { "calcs": [ "lastNotNull", "min" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "(sum by(server_name) (count_over_time({unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | status =~ \"[1234]..\" | server_name =~ \"[[server_name]].*\" [7d])) / sum by(server_name) (count_over_time({unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | server_name =~ \"[[server_name]].*\" [7d])))", "legendFormat": "{{server_name}}", "queryType": "range", "refId": "A" } ], "title": "5XX Requests Error Budgets", "transformations": [ { "id": "extractFields", "options": { "replace": true, "source": "Line" } }, { "id": "organize", "options": { "excludeByName": {}, "indexByName": { "body_bytes_sent": 9, "bytes_sent": 8, "gzip_ration": 11, "post": 12, "referrer": 10, "remote_addr": 3, "remote_user": 6, "request": 4, "request_length": 7, "request_time": 15, "server_name": 2, "status": 1, "time_local": 0, "upstream_addr": 13, "upstream_connect_time": 17, "upstream_header_time": 18, "upstream_response_time": 16, "upstream_status": 14, "user_agent": 5 }, "renameByName": {} } } ], "type": "timeseries" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "max": 1.01, "min": 0, "thresholds": { "mode": "absolute", "steps": [ { "color": "light-red", "value": null }, { "color": "transparent", "value": 0.99 } ] }, "unit": "percentunit" }, "overrides": [] }, "gridPos": { "h": 7, "w": 12, "x": 12, "y": 7 }, "id": 10, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "legend": { "calcs": [ "lastNotNull", "min" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "(sum by(server_name) (count_over_time({unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | status =~ \"[1235]..\" | server_name =~ \"[[server_name]].*\" [7d])) / sum by(server_name) (count_over_time({unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | server_name =~ \"[[server_name]].*\" [7d])))", "legendFormat": "{{server_name}}", "queryType": "range", "refId": "A" } ], "title": "4XX Requests Error Budgets", "transformations": [ { "id": "extractFields", "options": { "replace": true, "source": "Line" } }, { "id": "organize", "options": { "excludeByName": {}, "indexByName": { "body_bytes_sent": 9, "bytes_sent": 8, "gzip_ration": 11, "post": 12, "referrer": 10, "remote_addr": 3, "remote_user": 6, "request": 4, "request_length": 7, "request_time": 15, "server_name": 2, "status": 1, "time_local": 0, "upstream_addr": 13, "upstream_connect_time": 17, "upstream_header_time": 18, "upstream_response_time": 16, "upstream_status": 14, "user_agent": 5 }, "renameByName": {} } } ], "type": "timeseries" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 14 }, "id": 8, "options": { "dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{unit=~\"[[service]].*\"}", "queryType": "range", "refId": "A" } ], "title": "Log Errors", "type": "logs" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "filterable": false, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green" }, { "color": "red", "value": 80 } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "Time" }, "properties": [ { "id": "custom.width", "value": 167 } ] } ] }, "gridPos": { "h": 14, "w": 24, "x": 0, "y": 22 }, "id": 7, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "cellHeight": "sm", "footer": { "countRows": false, "enablePagination": false, "fields": "", "reducer": [ "sum" ], "show": false }, "showHeader": true, "sortBy": [] }, "pluginVersion": "10.2.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | status =~ \"5..\" | server_name =~ \"[[server_name]].*\"", "queryType": "range", "refId": "A" } ], "title": "5XX Requests Errors", "transformations": [ { "id": "extractFields", "options": { "keepTime": false, "replace": false, "source": "Line" } }, { "id": "organize", "options": { "excludeByName": { "Line": true, "id": true, "labels": true, "time_local": true, "tsNs": true }, "indexByName": { "Line": 21, "Time": 0, "body_bytes_sent": 10, "bytes_sent": 9, "gzip_ration": 12, "id": 23, "labels": 20, "post": 13, "referrer": 11, "remote_addr": 4, "remote_user": 7, "request": 5, "request_length": 8, "request_time": 16, "server_name": 3, "status": 2, "time_local": 1, "tsNs": 22, "upstream_addr": 14, "upstream_connect_time": 18, "upstream_header_time": 19, "upstream_response_time": 17, "upstream_status": 15, "user_agent": 6 }, "renameByName": {} } } ], "type": "table" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "filterable": false, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green" }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 14, "w": 24, "x": 0, "y": 36 }, "id": 11, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "cellHeight": "sm", "footer": { "countRows": false, "enablePagination": false, "fields": "", "reducer": [ "sum" ], "show": false }, "showHeader": true }, "pluginVersion": "10.2.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | status =~ \"4..\" | server_name =~ \"[[server_name]].*\"", "queryType": "range", "refId": "A" } ], "title": "4XX Requests Errors", "transformations": [ { "id": "extractFields", "options": { "replace": true, "source": "Line" } }, { "id": "organize", "options": { "excludeByName": {}, "indexByName": { "body_bytes_sent": 9, "bytes_sent": 8, "gzip_ration": 11, "post": 12, "referrer": 10, "remote_addr": 3, "remote_user": 6, "request": 4, "request_length": 7, "request_time": 15, "server_name": 2, "status": 1, "time_local": 0, "upstream_addr": 13, "upstream_connect_time": 17, "upstream_header_time": 18, "upstream_response_time": 16, "upstream_status": 14, "user_agent": 5 }, "renameByName": {} } } ], "type": "table" } ], "preload": false, "refresh": "", "schemaVersion": 40, "tags": [], "templating": { "list": [ { "allValue": ".+", "current": { "text": "All", "value": "$__all" }, "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "definition": "query_result(max by (name) (node_systemd_unit_state))", "includeAll": true, "multi": true, "name": "service", "options": [], "query": { "qryType": 3, "query": "query_result(max by (name) (node_systemd_unit_state))", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "/name=\"(?.*)\\.service\"/", "sort": 1, "type": "query" }, { "current": { "text": ".+", "value": ".+" }, "name": "server_name", "options": [ { "selected": true, "text": ".+", "value": ".+" } ], "query": ".+", "type": "textbox" } ] }, "time": { "from": "now-6h", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Errors", "uid": "d66242cf-71e8-417c-8ef7-51b0741545df", "version": 32, "weekStart": "" } ================================================ FILE: modules/blocks/monitoring/dashboards/Health.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "links": [], "panels": [ { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 4, "panels": [], "repeat": "hostname", "title": "${hostname}", "type": "row" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "footer": { "reducers": [] }, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 4, "w": 12, "x": 0, "y": 1 }, "id": 3, "maxDataPoints": 400, "options": { "cellHeight": "sm", "showHeader": true }, "pluginVersion": "12.4.0", "targets": [ { "disableTextWrap": false, "editorMode": "builder", "expr": "node_os_info", "fullMetaSearch": false, "includeNullMetadata": true, "legendFormat": "__auto", "range": true, "refId": "A", "useBackend": false } ], "title": "OS Versions", "transformations": [ { "id": "labelsToFields", "options": { "keepLabels": [ "build_id", "domain", "hostname", "id", "instance", "job", "name", "pretty_name", "version", "version_codename", "version_id" ], "mode": "columns" } }, { "id": "groupBy", "options": { "fields": { "Time": { "aggregations": [ "firstNotNull" ], "operation": "aggregate" }, "pretty_name": { "aggregations": [], "operation": "groupby" } } } } ], "type": "table" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 }, "id": 1, "options": { "legend": { "calcs": [ "lastNotNull", "max" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "node_hwmon_temp_celsius{hostname=~\"$hostname\"}", "instant": false, "legendFormat": "{{chip}} - {{sensor}}", "range": true, "refId": "A" } ], "title": "Temperature", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "axisPlacement": "auto", "fillOpacity": 70, "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineWidth": 0 }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 } ] } }, "overrides": [] }, "gridPos": { "h": 4, "w": 24, "x": 0, "y": 9 }, "id": 5, "interval": "1m", "maxDataPoints": 200, "options": { "colWidth": 1, "legend": { "displayMode": "list", "placement": "bottom", "showLegend": false }, "rowHeight": 0.8, "showValue": "never", "tooltip": { "hideZeros": false, "mode": "none", "sort": "none" } }, "pluginVersion": "12.4.0", "repeat": "hostname", "repeatDirection": "h", "targets": [ { "editorMode": "code", "expr": "node_zfs_zpool_state{hostname=~\"$hostname\", state=\"online\"} > 0", "legendFormat": "{{zpool}} - {{state}}", "range": true, "refId": "A" } ], "title": "ZFS Pools", "type": "status-history" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line+area" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": 0 }, { "color": "transparent", "value": 604808 } ] }, "unit": "s" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 }, "id": 2, "options": { "legend": { "calcs": [ "lastNotNull" ], "displayMode": "table", "placement": "bottom", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "ssl_certificate_expiry_seconds", "legendFormat": "{{exported_hostname}}: {{subject}} {{path}}", "range": true, "refId": "A" } ], "title": "Certificate Remaining Validity", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] }, "unit": "years" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 }, "id": 7, "options": { "legend": { "calcs": [ "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.4.0", "targets": [ { "editorMode": "builder", "expr": "scrutiny_smart_power_on_hours{hostname=~\"$hostname\"} / (24 * 365)", "legendFormat": "{{device_name}}", "range": true, "refId": "A" } ], "title": "Operating Years", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "continuous-YlRd" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMax": 100, "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineStyle": { "fill": "solid" }, "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] }, "unit": "percent" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 21 }, "id": 6, "options": { "legend": { "calcs": [ "lastNotNull", "max" ], "displayMode": "table", "placement": "bottom", "showLegend": true, "width": 400 }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.4.0", "targets": [ { "disableTextWrap": false, "editorMode": "builder", "expr": "sum by(hostname, domain, mountpoint, device) (node_filesystem_free_bytes{hostname=~\"$hostname\"})", "fullMetaSearch": false, "hide": true, "includeNullMetadata": false, "legendFormat": "__auto", "range": true, "refId": "A", "useBackend": false }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "builder", "expr": "sum by(hostname, domain, mountpoint, device) (node_filesystem_size_bytes{hostname=~\"$hostname\"})", "hide": true, "instant": false, "legendFormat": "__auto", "range": true, "refId": "B" }, { "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__" }, "expression": "(1 - $A / $B) * 100", "refId": "Disk Full", "type": "math" } ], "title": "Filesystem Disk Usage", "transformations": [ { "id": "joinByField", "options": { "byField": "Time", "mode": "outer" } } ], "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 21 }, "id": 9, "options": { "legend": { "calcs": [ "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.4.0", "targets": [ { "editorMode": "builder", "expr": "scrutiny_smart_power_on_hours{hostname=~\"$hostname\"}", "legendFormat": "{{device_name}}", "range": true, "refId": "A" } ], "title": "Operating Years", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "footer": { "reducers": [] }, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 29 }, "id": 8, "options": { "cellHeight": "sm", "showHeader": true }, "pluginVersion": "12.4.0", "targets": [ { "editorMode": "builder", "exemplar": false, "expr": "scrutiny_device_info{hostname=~\"$hostname\"}", "format": "table", "instant": true, "legendFormat": "{{device_name}}", "range": false, "refId": "A" } ], "title": "Disk Info", "transformations": [ { "id": "organize", "options": { "excludeByName": { "Time": true, "Value": true, "__name__": true, "domain": true, "hostname": true, "instance": true, "job": true, "wwn": false }, "includeByName": {}, "indexByName": {}, "renameByName": {} } } ], "type": "table" } ], "preload": false, "schemaVersion": 42, "tags": [], "templating": { "list": [ { "current": { "text": "baryum", "value": "baryum" }, "definition": "label_values(up,hostname)", "name": "hostname", "options": [], "query": { "qryType": 1, "query": "label_values(up,hostname)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "regexApplyTo": "value", "type": "query" } ] }, "time": { "from": "now-30m", "to": "now" }, "timepicker": {}, "timezone": "browser", "title": "Node Health", "uid": "edhuvl28vpjwge", "version": 25, "weekStart": "" } ================================================ FILE: modules/blocks/monitoring/dashboards/Performance.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": 6, "links": [], "panels": [ { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 12, "panels": [], "title": "Node", "type": "row" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": 3600000, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "mbytes" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "A" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "decimals" }, { "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 10, 10 ], "fill": "dash" } }, { "id": "custom.lineWidth", "value": 2 } ] }, { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 10 }, { "id": "custom.axisPlacement", "value": "auto" }, { "id": "custom.stacking", "value": { "group": "A", "mode": "none" } } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 }, "id": 20, "options": { "legend": { "calcs": [ "max", "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_memory_full_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "full stall time", "range": true, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "sum(node_memory_MemTotal_bytes{instance=\"127.0.0.1:9112\"}) / 1000000 - sum(netdata_mem_available_MiB_average{instance=~\"$instance\"})", "hide": false, "instant": false, "legendFormat": "remaining", "range": true, "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_memory_usage_MiB_average{instance=~\"$instance\", service_name=~\"$service\", dimension=\"ram\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{service_name}}", "range": true, "refId": "used", "useBackend": false } ], "title": "Memory", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": 3600000, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "mbytes" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "A" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "decimals" }, { "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 10, 10 ], "fill": "dash" } }, { "id": "custom.lineWidth", "value": 2 } ] }, { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 10 }, { "id": "custom.axisPlacement", "value": "auto" }, { "id": "custom.stacking", "value": { "group": "A", "mode": "none" } } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 }, "id": 23, "options": { "legend": { "calcs": [ "max", "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_memory_full_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "full stall time", "range": true, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "sum(node_memory_SwapFree_bytes{instance=~\"127.0.0.1:9112\"}) / 1000000", "hide": false, "instant": false, "legendFormat": "remaining", "range": true, "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_memory_usage_MiB_average{hostname=~\"$hostname\", service_name=~\"$service\", dimension=\"swap\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{service_name}}", "range": true, "refId": "used", "useBackend": false } ], "title": "Swap", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": 3600000, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "percent" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 34 } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 }, "id": 22, "options": { "legend": { "calcs": [ "max", "sum" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_cpu_some_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "some stall time", "range": true, "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_cpu_utilization_percentage_average{hostname=~\"$hostname\", service_name=~\"$service\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{service_name}} / {{dimension}}", "range": true, "refId": "used", "useBackend": false } ], "title": "CPU", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": 3600000, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "Kibits" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "A" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 12 }, { "id": "custom.stacking", "value": { "group": "A", "mode": "none" } } ] }, { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 17 }, { "id": "custom.stacking", "value": { "group": "A", "mode": "none" } } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, "id": 21, "options": { "legend": { "calcs": [ "max", "sum" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_io_full_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "full stall time", "range": true, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_io_some_pressure_stall_time_ms_average{instance=~\"$instance\"} * -1", "hide": false, "instant": false, "legendFormat": "some stall time", "range": true, "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{instance=~\"$instance\", service_name=~\"$service\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{service_name}} / {{dimension}}", "range": true, "refId": "used", "useBackend": false } ], "title": "Disk I/O", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "Kibits" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 17 }, "id": 18, "options": { "legend": { "calcs": [ "max", "sum" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_disk_io_KiB_persec_average{hostname=~\"$hostname\", chart=~\"disk.sd.+\"}", "instant": false, "legendFormat": "{{device}} / {{dimension}}", "range": true, "refId": "A" } ], "title": "Disk I/O", "type": "timeseries" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 }, "id": 4, "panels": [], "title": "Network Requests", "type": "row" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "custom": { "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "scaleDistribution": { "type": "linear" } } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 }, "id": 17, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "calculate": false, "cellGap": 1, "color": { "exponent": 0.5, "fill": "dark-orange", "mode": "scheme", "reverse": false, "scale": "exponential", "scheme": "RdBu", "steps": 62 }, "exemplars": { "color": "rgba(255,0,255,0.7)" }, "filterValues": { "le": 1e-9 }, "legend": { "show": true }, "rowsFrame": { "layout": "auto" }, "tooltip": { "mode": "single", "showColorScale": false, "yHistogram": false }, "yAxis": { "axisPlacement": "left", "decimals": 0, "reverse": false, "unit": "s" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | request_time > 100", "legendFormat": "{{server_name}}", "queryType": "range", "refId": "A" } ], "title": "Slow Requests Histogram > 100ms", "transformations": [ { "id": "extractFields", "options": { "keepTime": false, "replace": false, "source": "Line" } }, { "id": "organize", "options": { "excludeByName": { "Line": true, "body_bytes_sent": true, "bytes_sent": true, "gzip_ration": true, "id": true, "labels": true, "post": true, "referrer": true, "remote_addr": true, "remote_user": true, "request": true, "request_length": true, "server_name": true, "status": true, "time_local": true, "tsNs": true, "upstream_addr": true, "upstream_connect_time": true, "upstream_header_time": true, "upstream_response_time": true, "upstream_status": true, "user_agent": true }, "indexByName": {}, "renameByName": {} } }, { "id": "convertFieldType", "options": { "conversions": [ { "destinationType": "number", "targetField": "request_time" } ], "fields": {} } }, { "id": "heatmap", "options": { "xBuckets": { "mode": "size", "value": "" }, "yBuckets": { "mode": "size", "scale": { "log": 2, "type": "log" }, "value": "" } } } ], "type": "heatmap" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "points", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "ms" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 }, "id": 2, "options": { "legend": { "calcs": [ "max", "mean", "variance" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | request_time > 100", "legendFormat": "", "queryType": "range", "refId": "A" } ], "title": "Requests > 100ms", "transformations": [ { "id": "extractFields", "options": { "keepTime": true, "replace": true, "source": "labels" } }, { "id": "organize", "options": { "excludeByName": { "body_bytes_sent": true, "bytes_sent": true, "gzip_ration": true, "job": true, "line": true, "post": true, "referrer": true, "remote_addr": true, "remote_user": true, "request": true, "request_length": true, "status": true, "time_local": true, "unit": true, "upstream_addr": true, "upstream_connect_time": true, "upstream_header_time": true, "upstream_response_time": true, "upstream_status": true, "user_agent": true }, "indexByName": {}, "renameByName": {} } }, { "id": "convertFieldType", "options": { "conversions": [ { "dateFormat": "", "destinationType": "number", "targetField": "request_time" } ], "fields": {} } }, { "id": "partitionByValues", "options": { "fields": [ "server_name" ] } }, { "id": "renameByRegex", "options": { "regex": "request_time (.*)", "renamePattern": "$1" } } ], "type": "timeseries" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "filterable": false, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 34 }, "id": 3, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "cellHeight": "sm", "footer": { "countRows": false, "enablePagination": false, "fields": "", "reducer": [ "sum" ], "show": false }, "showHeader": true }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | request_time > 1", "queryType": "range", "refId": "A" } ], "title": "Network Requests Above 1s", "transformations": [ { "id": "extractFields", "options": { "replace": true, "source": "Line" } } ], "type": "table" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 42 }, "id": 7, "panels": [], "title": "Databases", "type": "row" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "duration_ms" }, "properties": [ { "id": "custom.width", "value": 100 } ] }, { "matcher": { "id": "byName", "options": "unit" }, "properties": [ { "id": "custom.width", "value": 150 } ] } ] }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 43 }, "id": 6, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": [ "sum" ], "show": false }, "showHeader": true }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"postgresql.service\"} | regexp \".*duration: (?P[0-9.]+) ms (?P.*)\" | duration_ms > 500 | __error__ != \"LabelFilterErr\"", "queryType": "range", "refId": "A" } ], "title": "Slow DB Queries", "transformations": [ { "id": "extractFields", "options": { "replace": true, "source": "labels" } }, { "id": "organize", "options": { "excludeByName": { "job": true }, "indexByName": { "duration_ms": 0, "job": 1, "statement": 3, "unit": 2 }, "renameByName": {} } } ], "type": "table" } ], "preload": false, "refresh": "1m", "schemaVersion": 40, "tags": [], "templating": { "list": [ { "allValue": ".*", "current": { "text": [ "baryum" ], "value": [ "baryum" ] }, "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "definition": "label_values(netdata_systemd_service_unit_state_state_average,hostname)", "includeAll": false, "multi": true, "name": "hostname", "options": [], "query": { "qryType": 1, "query": "label_values(netdata_systemd_service_unit_state_state_average,hostname)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "type": "query" }, { "allValue": ".*", "current": { "text": [ "All" ], "value": [ "$__all" ] }, "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "definition": "label_values(netdata_systemd_service_unit_state_state_average,unit_name)", "includeAll": true, "multi": true, "name": "service", "options": [], "query": { "qryType": 1, "query": "label_values(netdata_systemd_service_unit_state_state_average,unit_name)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "type": "query" } ] }, "time": { "from": "now-30m", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Performance", "uid": "e01156bf-cdba-42eb-9845-a401dd634d41", "version": 82, "weekStart": "" } ================================================ FILE: modules/blocks/monitoring/dashboards/Scraping_Jobs.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": 5, "links": [], "panels": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "id": 5, "options": { "dedupStrategy": "none", "enableInfiniteScrolling": false, "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "direction": "backward", "editorMode": "code", "expr": "{unit=~\"prometheus-.*-exporter.service\"}", "queryType": "range", "refId": "A" } ], "title": "Exporter Logs", "type": "logs" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "fixedColor": "red", "mode": "shades" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "id": 4, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "rate(net_conntrack_dialer_conn_failed_total{hostname=~\"$hostname\"}[2m]) > 0", "instant": false, "legendFormat": "{{dialer_name}} - {{reason}}", "range": true, "refId": "A" } ], "title": "Errors", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "fixedColor": "red", "mode": "thresholds" }, "custom": { "axisPlacement": "auto", "fillOpacity": 70, "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineWidth": 0, "spanNulls": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 1 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, "id": 3, "options": { "alignValue": "center", "legend": { "displayMode": "list", "placement": "bottom", "showLegend": false }, "mergeValues": true, "rowHeight": 0.9, "showValue": "never", "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "prometheus_sd_discovered_targets{hostname=~\"$hostname\"}", "hide": false, "instant": false, "legendFormat": "{{config}}", "range": true, "refId": "All" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "label_replace(increase((sum by(dialer_name) (net_conntrack_dialer_conn_failed_total{hostname=~\"$hostname\"}))[15m:1m]), \"config\", \"$1\", \"dialer_name\", \"(.*)\")", "hide": false, "instant": false, "legendFormat": "{{dialer_name}}", "range": true, "refId": "Failed" } ], "title": "Scraping jobs", "transformations": [ { "id": "labelsToFields", "options": { "keepLabels": [ "config" ], "mode": "columns" } }, { "id": "merge", "options": {} }, { "id": "organize", "options": { "excludeByName": { "prometheus_sd_discovered_targets": true }, "indexByName": {}, "renameByName": { "prometheus_sd_discovered_targets": "" } } }, { "id": "partitionByValues", "options": { "fields": [ "config" ] } } ], "type": "state-timeline" } ], "preload": false, "refresh": "", "schemaVersion": 42, "tags": [], "templating": { "list": [ { "current": { "text": "baryum", "value": "baryum" }, "definition": "label_values(up,hostname)", "includeAll": false, "name": "hostname", "options": [], "query": { "qryType": 1, "query": "label_values(up,hostname)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "type": "query" } ] }, "time": { "from": "now-6h", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Scraping Jobs", "uid": "debb763d-77aa-47bd-9290-2e02583c8ed2", "version": 24 } ================================================ FILE: modules/blocks/monitoring/docs/default.md ================================================ # Monitoring Block {#blocks-monitoring} Defined in [`/modules/blocks/monitoring.nix`](@REPO@/modules/blocks/monitoring.nix). This block sets up the monitoring stack for Self Host Blocks. It is composed of: - Grafana as the dashboard frontend. - Prometheus as the database for metrics. - Loki as the database for logs. ## Features {#services-monitoring-features} - Declarative [LDAP](#blocks-monitoring-options-shb.monitoring.ldap) Configuration. - Needed LDAP groups are created automatically. - Declarative [SSO](#blocks-monitoring-options-shb.monitoring.sso) Configuration. - When SSO is enabled, login with user and password is disabled. - Registration is enabled through SSO. - Access through [subdomain](#blocks-monitoring-options-shb.monitoring.subdomain) using reverse proxy. - Access through [HTTPS](#blocks-monitoring-options-shb.monitoring.ssl) using reverse proxy. - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#blocks-monitoring-usage-applicationdashboard) - Out of the box integration with [Scrutiny](https://github.com/AnalogJ/scrutiny) service for Hard Drives monitoring. [Manual](#blocks-monitoring-usage-scrutiny) ## Usage {#blocks-monitoring-usage} ### Initial Configuration {#blocks-monitoring-usage-configuration} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ```nix { shb.monitoring = { enable = true; subdomain = "grafana"; inherit domain; contactPoints = [ "me@example.com" ]; adminPassword.result = config.shb.sops.secret."monitoring/admin_password".result; secretKey.result = config.shb.sops.secret."monitoring/secret_key".result; sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; sharedSecret.result = config.shb.sops.secret."monitoring/oidcSecret".result; sharedSecretForAuthelia.result = config.shb.sops.secret."monitoring/oidcAutheliaSecret".result; }; }; shb.sops.secret."monitoring/admin_password".request = config.shb.monitoring.adminPassword.request; shb.sops.secret."monitoring/secret_key".request = config.shb.monitoring.secretKey.request; shb.sops.secret."monitoring/oidcSecret".request = config.shb.monitoring.sso.sharedSecret.request; shb.sops.secret."monitoring/oidcAutheliaSecret" = { request = config.shb.monitoring.sso.sharedSecretForAuthelia.request; settings.key = "monitoring/oidcSecret"; }; }; ``` Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. With that, Grafana, Prometheus, Loki and Promtail are setup! You can access `Grafana` at `grafana.example.com` with user `admin` and the password from the sops key `monitoring/admin_password`. The [user](#blocks-monitoring-options-shb.monitoring.ldap.userGroup) and [admin](#blocks-monitoring-options-shb.monitoring.ldap.adminGroup) LDAP groups are created automatically. ### SMTP {#blocks-monitoring-usage-smtp} I recommend adding an SMTP server configuration so you receive alerts by email: ```nix shb.monitoring.smtp = { from_address = "grafana@$example.com"; from_name = "Grafana"; host = "smtp.mailgun.org"; port = 587; username = "postmaster@mg.example.com"; passwordFile = config.sops.secrets."monitoring/smtp".path; }; sops.secrets."monitoring/secret_key" = { sopsFile = ./secrets.yaml; mode = "0400"; owner = "grafana"; group = "grafana"; restartUnits = [ "grafana.service" ]; }; ``` ### Log Optimization {#blocks-monitoring-usage-log-optimization} Since all logs are now stored in Loki, you can probably reduce the systemd journal retention time with: ```nix # See https://www.freedesktop.org/software/systemd/man/journald.conf.html#SystemMaxUse= services.journald.extraConfig = '' SystemMaxUse=2G SystemKeepFree=4G SystemMaxFileSize=100M MaxFileSec=day ''; ``` Other options are accessible through the upstream services modules. You might for example want to update the metrics retention time with: ```nix services.prometheus.retentionTime = "60d"; ``` ### Application Dashboard {#blocks-monitoring-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#blocks-monitoring-options-shb.monitoring.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Admin.services.Grafana = { sortOrder = 10; dashboard.request = config.shb.monitoring.dashboard.request; }; } ``` There is also an integration for the scrutiny service, see next section. ### Scrutiny {#blocks-monitoring-usage-scrutiny} Integration with the [Scrutiny](https://github.com/AnalogJ/scrutiny) service is enabled by default and setup automatically. The web interface will be served under the [scrutiny.subdomain](#blocks-monitoring-options-shb.monitoring.scrutiny.subdomain) option. If you don't want the web interface, set the option to `null`. For integration with the [dashboard contract](contracts-dashboard.html): ```nix { shb.homepage.servicesGroups.Admin.services.Scrutiny = { sortOrder = 11; dashboard.request = config.shb.monitoring.scrutiny.dashboard.request; }; } ``` ## Provisioning {#blocks-monitoring-provisioning} Self Host Blocks will create automatically the following resources: - For Grafana: - datasources - dashboards - contact points - notification policies - alerts - For Prometheus, the following exporters and related scrapers: - node - smartctl - nginx - For Loki, the following exporters and related scrapers: - systemd Those resources are namespaced as appropriate under the Self Host Blocks namespace: ![](./assets/folder.png) ## Errors Dashboard {#blocks-monitoring-error-dashboard} This dashboard is meant to be the first stop to understand why a service is misbehaving. ![](./assets/dashboards_Errors_1.png) ![](./assets/dashboards_Errors_2.png) The yellow and red dashed vertical bars correspond to the [Requests Error Budget Alert](#blocks-monitoring-budget-alerts) firing. ## Performance Dashboard {#blocks-monitoring-performance-dashboard} This dashboard is meant to be the first stop to understand why a service is performing poorly. ![Performance Dashboard Top Part](./assets/dashboards_Performance_1.png) ![Performance Dashboard Middle Part](./assets/dashboards_Performance_2.png) ![Performance Dashboard Bottom Part](./assets/dashboards_Performance_3.png) ## Nextcloud Dashboard {#blocks-monitoring-nextcloud-dashboard} See [Nextcloud service](./services-nextcloud.html#services-nextcloudserver-dashboard) manual. ## Deluge Dashboard {#blocks-monitoring-deluge-dashboard} This dashboard is used to monitor a [deluge](./services-deluge.html) instance. ![Deluge Dashboard Top Part](./assets/dashboards_Deluge_1.png) ![Deluge Dashboard Bottom Part](./assets/dashboards_Deluge_2.png) ## Backups Dashboard and Alert {#blocks-monitoring-backup} This dashboard shows Restic and BorgBackup backup jobs, or any job with "backup" in the systemd service name. ### Dashboard {#blocks-monitoring-backup-dashboard} Variables: - The "Job" variable allows to select one or more backup jobs. "All" is the default. - The "mountpoints" variable allows to select only relevant mountpoints for backup. "All" is the default. The most important graphs are the first three: - "Backup Jobs in the Past Week": Shows stats on all backup jobs that ran in the past. It is sorted by the "Failed" column in descending order. This way, one can directly see when a job has failures. - "Schedule": Shows when a job will run. The unit is "Datetime from Now" meaning it shows when a job ran or will run relative to the current time. An annotation will show up when the "Late Backups" alert fired or resolved. - "Backup jobs": Shows when a backup job ran. Normally, jobs running for less than 15 seconds will not show up in the graph. We crafted a query that still shows them but the length is 15 seconds, even if the backup job took less time to run. ![Backups Dashboard Top Part](./assets/dashboards_Backups_1.png) ![Backups Dashboard Middle Part](./assets/dashboards_Backups_2.png) ![Backups Dashboard Bottom Part](./assets/dashboards_Backups_3.png) ### Alerts {#blocks-monitoring-backup-alerts} - The "Late Backups" alert will fire if a backup job did not run at all in the last 24 hours or if all runs were failures in the last 24 hours. It will show up as annotations in the "Schedule" panel of the dashboard. ![Late Backups Alert Firing](./assets/alert_rules_LateBackups_1.png) ![Backups Alert Showing Up In Dashboard](./assets/dashboards_Backups_alert.png) ## Requests Error Budget Alert {#blocks-monitoring-budget-alerts} This alert will fire when the ratio between number of requests getting a 5XX response from a service and the total requests to that service exceeds 1%. ![Error Dashboard Top Part](./assets/alert_rules_5xx_1.png) ![Error Dashboard Bottom Part](./assets/alert_rules_5xx_2.png) ## SSL Certificates Dashboard and Alert {#blocks-monitoring-ssl} This dashboard shows Let's Encrypt renewal and setup jobs, or any job starting with "acme-" in the systemd service name. ### Dashboard {#blocks-monitoring-ssl-dashboard} Variables: - The "Job" variable allows to focus on one or more certificate. "All" is the default. Graphs: - "Certificate Remaining Validity": Shows in how long will certificates expire. It shows all files under `/var/lib/acme`. An annotation will show up when the "Certificate Did Not Renew" alert fired or resolved. - "Schedule": Shows when a job will run. The unit is "Datetime from Now" meaning it shows when a job ran or will run relative to the current time. - "Jobs in the Past Week": Shows stats on all renewal jobs that ran in the past. It is sorted by the "Failed" column in descending order. This way, one can directly see when a job has failures. Note, the stats is not accurate because detecting jobs taking taking less than 15 seconds is not supported well. - "Job Runs": Shows when a renewal job ran. Normally, jobs running for less than 15 seconds will not show up in the graph. We crafted a query that still shows them but the length is 100 seconds, even if the job took less time to run. ![SSL Dashboard No Filter](./assets/dashboards_SSL_all.png) ![SSL Dashboard Filter Failing](./assets/dashboards_SSL_fail.png) ### Alerts {#blocks-monitoring-ssl-alerts} - The "Certificate Did Not Renew" alert will fire if a backup job did not run at all in the last 24 hours or if all runs were failures in the last 24 hours. It will show up as annotations in the "Schedule" panel of the dashboard. ![Late SSL Jobs Alert Firing](./assets/alert_rules_LateSSL_1.png) ## Options Reference {#blocks-monitoring-options} ```{=include=} options id-prefix: blocks-monitoring-options- list-id: selfhostblocks-blocks-monitoring-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/monitoring/rules.json ================================================ [ { "uid": "f5246fa3-163f-4eae-9e1d-5b0fe2af0509", "title": "5XX Requests Error Budgets Under 99%", "condition": "threshold", "data": [ { "refId": "A", "queryType": "range", "relativeTimeRange": { "from": 21600, "to": 0 }, "datasourceUid": "cd6cc53e-840c-484d-85f7-96fede324006", "model": { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "(sum by(server_name) (count_over_time({unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | status =~ \"[1234]..\" | server_name =~ \".*\" [1h])) / sum by(server_name) (count_over_time({unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | server_name =~ \".*\" [1h])))", "intervalMs": 1000, "legendFormat": "{{server_name}}", "maxDataPoints": 43200, "queryType": "range", "refId": "A" } }, { "refId": "last", "relativeTimeRange": { "from": 0, "to": 0 }, "datasourceUid": "__expr__", "model": { "conditions": [ { "evaluator": { "params": [], "type": "gt" }, "operator": { "type": "and" }, "query": { "params": [ "B" ] }, "reducer": { "params": [], "type": "last" }, "type": "query" } ], "datasource": { "type": "__expr__", "uid": "__expr__" }, "expression": "A", "intervalMs": 1000, "maxDataPoints": 43200, "reducer": "last", "refId": "last", "type": "reduce" } }, { "refId": "threshold", "relativeTimeRange": { "from": 0, "to": 0 }, "datasourceUid": "__expr__", "model": { "conditions": [ { "evaluator": { "params": [ 0.99 ], "type": "lt" }, "operator": { "type": "and" }, "query": { "params": [ "C" ] }, "reducer": { "params": [], "type": "last" }, "type": "query" } ], "datasource": { "type": "__expr__", "uid": "__expr__" }, "expression": "last", "intervalMs": 1000, "maxDataPoints": 43200, "refId": "threshold", "type": "threshold" } } ], "dasboardUid": "d66242cf-71e8-417c-8ef7-51b0741545df", "panelId": 9, "noDataState": "OK", "execErrState": "Error", "for": "20m", "annotations": { "__dashboardUid__": "d66242cf-71e8-417c-8ef7-51b0741545df", "__panelId__": "9", "description": "", "runbook_url": "", "summary": "The error budget for a service for the last 1 hour is under 99%" }, "labels": { "role": "sysadmin" }, "isPaused": false }, { "uid": "ee817l3a88s1sd", "title": "Certificate Did Not Renew", "condition": "C", "data": [ { "refId": "A", "relativeTimeRange": { "from": 1800, "to": 0 }, "datasourceUid": "df80f9f5-97d7-4112-91d8-72f523a02b09", "model": { "adhocFilters": [], "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "min by(subject) (ssl_certificate_expiry_seconds)", "interval": "", "intervalMs": 15000, "legendFormat": "{{exported_hostname}}: {{subject}} {{path}}", "maxDataPoints": 43200, "range": true, "refId": "A" } }, { "refId": "B", "relativeTimeRange": { "from": 0, "to": 0 }, "datasourceUid": "__expr__", "model": { "conditions": [ { "evaluator": { "params": [], "type": "gt" }, "operator": { "type": "and" }, "query": { "params": [ "B" ] }, "reducer": { "params": [], "type": "last" }, "type": "query" } ], "datasource": { "type": "__expr__", "uid": "__expr__" }, "expression": "A", "intervalMs": 1000, "maxDataPoints": 43200, "reducer": "last", "refId": "B", "type": "reduce" } }, { "refId": "C", "relativeTimeRange": { "from": 0, "to": 0 }, "datasourceUid": "__expr__", "model": { "conditions": [ { "evaluator": { "params": [ 604800 ], "type": "lt" }, "operator": { "type": "and" }, "query": { "params": [ "C" ] }, "reducer": { "params": [], "type": "last" }, "type": "query" } ], "datasource": { "type": "__expr__", "uid": "__expr__" }, "expression": "B", "intervalMs": 1000, "maxDataPoints": 43200, "refId": "C", "type": "threshold" } } ], "dashboardUid": "ae818js0bvw8wb", "panelId": 3, "noDataState": "NoData", "execErrState": "Error", "for": "20m", "annotations": { "__dashboardUid__": "ae818js0bvw8wb", "__panelId__": "3", "description": "The expiry date of the certificate is 1 week from now.", "summary": "Certificate did not renew on time." }, "labels": { "role": "sysadmin" }, "isPaused": false }, { "uid": "df4doj5pomhvkf", "title": "Late Backups", "condition": "C", "data": [ { "refId": "A", "relativeTimeRange": { "from": 10800, "to": 0 }, "datasourceUid": "df80f9f5-97d7-4112-91d8-72f523a02b09", "model": { "adhocFilters": [], "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "(\n # Timer triggered at least once in the last 24h\n label_replace((\n time()\n -\n systemd_timer_last_trigger_seconds{name=~\".*backup.*.timer\"}\n ) < 24*60*60, \"name\", \"$1.service\", \"name\", \"(.*).timer\")\n AND on(name)\n # At least one failure in the last 24h\n (\n max_over_time(systemd_unit_state{name=~\".*backup.*.service\", state=\"failed\"}[24h]) > 0\n )\n AND on(name)\n # No successes in the last 24h\n (\n max_over_time(systemd_unit_state{name=~\".*backup.*.service\", state=\"inactive\"}[24h]) == 0\n )\n)", "instant": false, "interval": "", "intervalMs": 15000, "legendFormat": "{{name}}", "maxDataPoints": 43200, "range": true, "refId": "A" } }, { "refId": "B", "relativeTimeRange": { "from": 0, "to": 0 }, "datasourceUid": "__expr__", "model": { "conditions": [ { "evaluator": { "params": [], "type": "gt" }, "operator": { "type": "and" }, "query": { "params": [ "B" ] }, "reducer": { "params": [], "type": "last" }, "type": "query" } ], "datasource": { "type": "__expr__", "uid": "__expr__" }, "expression": "A", "intervalMs": 1000, "maxDataPoints": 43200, "reducer": "last", "refId": "B", "type": "reduce" } }, { "refId": "C", "relativeTimeRange": { "from": 0, "to": 0 }, "datasourceUid": "__expr__", "model": { "conditions": [ { "evaluator": { "params": [ 0 ], "type": "gt" }, "operator": { "type": "and" }, "query": { "params": [ "C" ] }, "reducer": { "params": [], "type": "last" }, "type": "query" } ], "datasource": { "type": "__expr__", "uid": "__expr__" }, "expression": "B", "intervalMs": 1000, "maxDataPoints": 43200, "refId": "C", "type": "threshold" } } ], "dashboardUid": "f05500d0-15ed-4719-b68d-fb898ca13cc8", "panelId": 15, "noDataState": "OK", "execErrState": "Error", "annotations": { "__dashboardUid__": "f05500d0-15ed-4719-b68d-fb898ca13cc8", "__panelId__": "15", "summary": "A backup did not run in the last 24 hours." }, "labels": { "role": "sysadmin" }, "isPaused": false } ] ================================================ FILE: modules/blocks/monitoring.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.monitoring; fqdn = "${cfg.subdomain}.${cfg.domain}"; commonLabels = { hostname = config.networking.hostName; domain = cfg.domain; }; roleClaim = "grafana_groups"; oauthScopes = [ "openid" "email" "profile" "groups" "${roleClaim}" ]; in { imports = [ ../../lib/module.nix ../blocks/authelia.nix ../blocks/lldap.nix ../blocks/nginx.nix ]; options.shb.monitoring = { enable = lib.mkEnableOption "selfhostblocks.monitoring"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Grafana will be served."; example = "grafana"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which Grafana will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; grafanaPort = lib.mkOption { type = lib.types.port; description = "Port where Grafana listens to HTTP requests."; default = 3000; }; prometheusPort = lib.mkOption { type = lib.types.port; description = "Port where Prometheus listens to HTTP requests."; default = 3001; }; lokiPort = lib.mkOption { type = lib.types.port; description = "Port where Loki listens to HTTP requests."; default = 3002; }; lokiMajorVersion = lib.mkOption { type = lib.types.enum [ 2 3 ]; description = '' Switching from version 2 to 3 requires manual intervention https://grafana.com/docs/loki/latest/setup/upgrade/#main--unreleased. So this let's the user upgrade at their own pace. ''; default = 2; }; debugLog = lib.mkOption { type = lib.types.bool; description = "Set to true to enable debug logging of the infrastructure serving Grafana."; default = false; example = true; }; orgId = lib.mkOption { type = lib.types.int; description = "Org ID where all self host blocks related config will be stored."; default = 1; }; dashboards = lib.mkOption { type = lib.types.listOf lib.types.path; description = "Dashboards to provision under 'Self Host Blocks' folder."; default = [ ]; }; contactPoints = lib.mkOption { type = lib.types.listOf lib.types.str; description = "List of email addresses to send alerts to"; default = [ ]; }; scrutiny = { enable = lib.mkEnableOption "scrutiny service" // { default = true; }; subdomain = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' If a string, this will be the subdomain under which the scrutiny web interface will be servced. If null, the web interface will not be served and only the prometheus metrics will be accessible. ''; default = "scrutiny"; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.scrutiny.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.monitoring.scrutiny.subdomain}.\${config.shb.monitoring.domain}"; internalUrl = "http://127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}"; internalUrlText = "https://127.0.0.1.\${config.services.scrutiny.settings.web.listen.port}"; }; }; }; }; adminPassword = lib.mkOption { description = "Initial admin password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "grafana"; group = "grafana"; restartUnits = [ "grafana.service" ]; }; }; }; secretKey = lib.mkOption { description = "Secret key used for signing."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "grafana"; group = "grafana"; restartUnits = [ "grafana.service" ]; }; }; }; smtp = lib.mkOption { description = "SMTP options."; default = null; type = lib.types.nullOr ( lib.types.submodule { options = { from_address = lib.mkOption { type = lib.types.str; description = "SMTP address from which the emails originate."; example = "vaultwarden@mydomain.com"; }; from_name = lib.mkOption { type = lib.types.str; description = "SMTP name from which the emails originate."; default = "Grafana"; }; host = lib.mkOption { type = lib.types.str; description = "SMTP host to send the emails to."; }; port = lib.mkOption { type = lib.types.port; description = "SMTP port to send the emails to."; default = 25; }; username = lib.mkOption { type = lib.types.str; description = "Username to connect to the SMTP host."; }; passwordFile = lib.mkOption { type = lib.types.str; description = "File containing the password to connect to the SMTP host."; }; }; } ); }; ldap = lib.mkOption { description = '' Setup LDAP integration. ''; default = { }; type = lib.types.submodule { options = { userGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to login to Grafana."; default = "monitoring_user"; }; adminGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be admins in Grafana."; default = "monitoring_admin"; }; }; }; }; sso = lib.mkOption { description = '' Setup SSO integration. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "SSO integration."; authEndpoint = lib.mkOption { type = lib.types.str; default = null; description = "Endpoint to the SSO provider."; example = "https://authelia.example.com"; }; clientID = lib.mkOption { type = lib.types.str; description = "Client ID for the OIDC endpoint."; default = "grafana"; }; authorization_policy = lib.mkOption { type = lib.types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; sharedSecret = lib.mkOption { description = "OIDC shared secret for Grafana."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "grafana"; restartUnits = [ "grafana.service" ]; }; }; }; sharedSecretForAuthelia = lib.mkOption { description = "OIDC shared secret for Authelia. Must be the same as `sharedSecret`"; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; ownerText = "config.shb.authelia.autheliaUser"; owner = config.shb.authelia.autheliaUser; }; }; }; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.monitoring.subdomain}.\${config.shb.monitoring.domain}"; internalUrl = "https://${cfg.subdomain}.${cfg.domain}"; internalUrlText = "https://\${config.shb.monitoring.subdomain}.\${config.shb.monitoring.domain}"; }; }; }; }; config = lib.mkMerge [ (lib.mkIf cfg.enable { assertions = [ { assertion = builtins.length cfg.contactPoints > 0; message = "Must have at least one contact point for alerting"; } ]; shb.postgresql.ensures = [ { username = "grafana"; database = "grafana"; } ]; services.grafana = { enable = true; settings = { database = { host = "/run/postgresql"; user = "grafana"; name = "grafana"; type = "postgres"; # Uses peer auth for local users, so we don't need a password. # Here's the syntax anyway for future refence: # password = "$__file{/run/secrets/homeassistant/dbpass}"; }; security = { secret_key = "$__file{${cfg.secretKey.result.path}}"; disable_initial_admin_creation = false; # Enable when LDAP support is configured. admin_password = "$__file{${cfg.adminPassword.result.path}}"; # Remove when LDAP support is configured. }; server = { http_addr = "127.0.0.1"; http_port = cfg.grafanaPort; domain = fqdn; root_url = "https://${fqdn}"; router_logging = cfg.debugLog; }; smtp = lib.mkIf (!(isNull cfg.smtp)) { enabled = true; inherit (cfg.smtp) from_address from_name; host = "${cfg.smtp.host}:${toString cfg.smtp.port}"; user = cfg.smtp.username; password = "$__file{${cfg.smtp.passwordFile}}"; }; }; }; }) (lib.mkIf cfg.enable { shb.monitoring.dashboards = [ ./monitoring/dashboards/Errors.json ./monitoring/dashboards/Performance.json ./monitoring/dashboards/Scraping_Jobs.json ]; services.grafana.provision = { dashboards.settings = lib.mkIf (cfg.dashboards != [ ]) { apiVersion = 1; providers = [ { folder = "Self Host Blocks"; options.path = pkgs.symlinkJoin { name = "dashboards"; paths = map (p: pkgs.runCommand "dashboard" { } "mkdir $out; cp ${p} $out") cfg.dashboards; }; allowUiUpdates = true; disableDeletion = true; } ]; }; datasources.settings = { apiVersion = 1; datasources = [ { inherit (cfg) orgId; name = "Prometheus"; type = "prometheus"; url = "http://127.0.0.1:${toString config.services.prometheus.port}"; uid = "df80f9f5-97d7-4112-91d8-72f523a02b09"; isDefault = true; version = 1; } { inherit (cfg) orgId; name = "Loki"; type = "loki"; url = "http://127.0.0.1:${toString config.services.loki.configuration.server.http_listen_port}"; uid = "cd6cc53e-840c-484d-85f7-96fede324006"; version = 1; } ]; deleteDatasources = [ { inherit (cfg) orgId; name = "Prometheus"; } { inherit (cfg) orgId; name = "Loki"; } ]; }; alerting.contactPoints.settings = { apiVersion = 1; contactPoints = [ { inherit (cfg) orgId; name = "grafana-default-email"; receivers = lib.optionals ((builtins.length cfg.contactPoints) > 0) [ { uid = "sysadmin"; type = "email"; settings.addresses = lib.concatStringsSep ";" cfg.contactPoints; } ]; } ]; }; alerting.policies.settings = { apiVersion = 1; policies = [ { inherit (cfg) orgId; receiver = "grafana-default-email"; group_by = [ "grafana_folder" "alertname" ]; group_wait = "30s"; group_interval = "5m"; repeat_interval = "4h"; } ]; # resetPolicies seems to happen after setting the above policies, effectively rolling back # any updates. }; alerting.rules.settings = let rules = builtins.fromJSON (builtins.readFile ./monitoring/rules.json); in { apiVersion = 1; groups = [ { inherit (cfg) orgId; name = "SysAdmin"; folder = "Self Host Blocks"; interval = "10m"; inherit rules; } ]; # deleteRules seems to happen after creating the above rules, effectively rolling back # any updates. }; }; }) (lib.mkIf cfg.enable { services.prometheus = { enable = true; port = cfg.prometheusPort; globalConfig = { scrape_interval = "15s"; }; }; services.loki = { enable = true; dataDir = "/var/lib/loki"; package = if cfg.lokiMajorVersion == 3 then pkgs.grafana-loki else # Comes from https://github.com/NixOS/nixpkgs/commit/8f95320f39d7e4e4a29ee70b8718974295a619f4 (pkgs.grafana-loki.overrideAttrs ( finalAttrs: previousAttrs: rec { version = "2.9.6"; src = pkgs.fetchFromGitHub { owner = "grafana"; repo = "loki"; rev = "v${version}"; hash = "sha256-79hK7axHf6soku5DvdXkE/0K4WKc4pnS9VMbVc1FS2I="; }; subPackages = [ "cmd/loki" "cmd/loki-canary" "clients/cmd/promtail" "cmd/logcli" # Removes "cmd/lokitool" ]; ldflags = let t = "github.com/grafana/loki/pkg/util/build"; in [ "-s" "-w" "-X ${t}.Version=${version}" "-X ${t}.BuildUser=nix@nixpkgs" "-X ${t}.BuildDate=unknown" "-X ${t}.Branch=unknown" "-X ${t}.Revision=unknown" ]; } )); configuration = { auth_enabled = false; server.http_listen_port = cfg.lokiPort; ingester = { lifecycler = { address = "127.0.0.1"; ring = { kvstore.store = "inmemory"; replication_factor = 1; }; final_sleep = "0s"; }; chunk_idle_period = "5m"; chunk_retain_period = "30s"; }; schema_config = { configs = [ { from = "2018-04-15"; store = "boltdb"; object_store = "filesystem"; schema = "v9"; index.prefix = "index_"; index.period = "168h"; } ]; }; storage_config = { boltdb.directory = "/tmp/loki/index"; filesystem.directory = "/tmp/loki/chunks"; }; limits_config = { enforce_metric_name = false; reject_old_samples = true; reject_old_samples_max_age = "168h"; }; chunk_store_config = { max_look_back_period = 0; }; table_manager = { chunk_tables_provisioning = { inactive_read_throughput = 0; inactive_write_throughput = 0; provisioned_read_throughput = 0; provisioned_write_throughput = 0; }; index_tables_provisioning = { inactive_read_throughput = 0; inactive_write_throughput = 0; provisioned_read_throughput = 0; provisioned_write_throughput = 0; }; retention_deletes_enabled = false; retention_period = 0; }; }; }; services.promtail = { enable = true; configuration = { server = { http_listen_port = 9080; grpc_listen_port = 0; }; positions.filename = "/tmp/positions.yaml"; client.url = "http://localhost:${toString config.services.loki.configuration.server.http_listen_port}/api/prom/push"; scrape_configs = [ { job_name = "systemd"; journal = { json = false; max_age = "12h"; path = "/var/log/journal"; # matches = "_TRANSPORT=kernel"; labels = { domain = cfg.domain; hostname = config.networking.hostName; job = "systemd-journal"; }; }; relabel_configs = [ { source_labels = [ "__journal__systemd_unit" ]; target_label = "unit"; } ]; } ]; }; }; services.nginx = { enable = true; virtualHosts.${fqdn} = { forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; locations."/" = { proxyPass = "http://${toString config.services.grafana.settings.server.http_addr}:${toString config.services.grafana.settings.server.http_port}"; proxyWebsockets = true; extraConfig = '' proxy_set_header Host $host; ''; }; }; }; }) (lib.mkIf cfg.enable { services.prometheus.scrapeConfigs = [ { job_name = "node"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.node.port}" ]; labels = commonLabels; } ]; } { job_name = "netdata"; metrics_path = "/api/v1/allmetrics"; params.format = [ "prometheus" ]; honor_labels = true; static_configs = [ { targets = [ "127.0.0.1:19999" ]; labels = commonLabels; } ]; } { job_name = "smartctl"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.smartctl.port}" ]; labels = commonLabels; } ]; } { job_name = "prometheus_internal"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.prometheus.port}" ]; labels = commonLabels; } ]; } { job_name = "systemd"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.systemd.port}" ]; labels = commonLabels; } ]; } ] ++ (lib.lists.optional config.services.nginx.enable { job_name = "nginx"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.nginx.port}" ]; labels = commonLabels; } ]; # }) ++ (lib.optional (builtins.length (lib.attrNames config.services.redis.servers) > 0) { # job_name = "redis"; # static_configs = [ # { # targets = ["127.0.0.1:${toString config.services.prometheus.exporters.redis.port}"]; # } # ]; # }) ++ (lib.optional (builtins.length (lib.attrNames config.services.openvpn.servers) > 0) { # job_name = "openvpn"; # static_configs = [ # { # targets = ["127.0.0.1:${toString config.services.prometheus.exporters.openvpn.port}"]; # } # ]; }) ++ (lib.optional config.services.dnsmasq.enable { job_name = "dnsmasq"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.dnsmasq.port}" ]; labels = commonLabels; } ]; }); services.prometheus.exporters.nginx = lib.mkIf config.services.nginx.enable { enable = true; port = 9111; listenAddress = "127.0.0.1"; scrapeUri = "http://localhost:80/nginx_status"; }; services.prometheus.exporters.node = { enable = true; # https://github.com/prometheus/node_exporter#collectors enabledCollectors = [ "arp" "cpu" "cpufreq" "diskstats" "dmi" "edac" "entropy" "filefd" "filesystem" "hwmon" "loadavg" "meminfo" "netclass" "netdev" "netstat" "nvme" "os" "pressure" "rapl" "schedstat" "stat" "thermal_zone" "time" "uname" "vmstat" "zfs" # Disabled by default "cgroups" "drm" "ethtool" "logind" "wifi" ]; port = 9112; listenAddress = "127.0.0.1"; }; # https://github.com/nixos/nixpkgs/commit/12c26aca1fd55ab99f831bedc865a626eee39f80 # TODO: remove when https://github.com/NixOS/nixpkgs/pull/205165 is merged services.udev.extraRules = '' SUBSYSTEM=="nvme", KERNEL=="nvme[0-9]*", GROUP="disk" ''; services.prometheus.exporters.smartctl = { enable = true; port = 9115; listenAddress = "127.0.0.1"; }; # services.prometheus.exporters.redis = lib.mkIf (builtins.length (lib.attrNames config.services.redis.servers) > 0) { # enable = true; # port = 9119; # listenAddress = "127.0.0.1"; # }; # services.prometheus.exporters.openvpn = lib.mkIf (builtins.length (lib.attrNames config.services.openvpn.servers) > 0) { # enable = true; # port = 9121; # listenAddress = "127.0.0.1"; # statusPaths = lib.mapAttrsToList (name: _config: "/tmp/openvpn/${name}.status") config.services.openvpn.servers; # }; services.prometheus.exporters.dnsmasq = lib.mkIf config.services.dnsmasq.enable { enable = true; port = 9211; listenAddress = "127.0.0.1"; }; services.prometheus.exporters.systemd = { enable = true; port = 9116; listenAddress = "127.0.0.1"; }; services.nginx.statusPage = lib.mkDefault config.services.nginx.enable; services.netdata = { enable = true; config = { # web.mode = "none"; # web."bind to" = "127.0.0.1:19999"; global = { "debug log" = "syslog"; "access log" = "syslog"; "error log" = "syslog"; }; }; }; nixpkgs.overlays = [ (final: prev: { prometheus-systemd-exporter = prev.prometheus-systemd-exporter.overrideAttrs { src = final.fetchFromGitHub { owner = "ibizaman"; repo = prev.prometheus-systemd-exporter.pname; # rev = "v${prev.prometheus-systemd-exporter.version}"; rev = "next_timer"; sha256 = "sha256-jzkh/616tsJbNxFtZ0xbdBQc16TMIYr9QOkPaeQw8xA="; }; vendorHash = "sha256-4hsQ1417jLNOAqGkfCkzrmEtYR4YLLW2j0CiJtPg6GI="; }; }) ]; }) (lib.mkIf (cfg.enable && cfg.sso.enable) { shb.lldap.ensureGroups = { ${cfg.ldap.userGroup} = { }; ${cfg.ldap.adminGroup} = { }; }; shb.authelia.extraDefinitions = { user_attributes.${roleClaim}.expression = # Roles are: None, Viewer, Editor, Admin, GrafanaAdmin ''"${cfg.ldap.adminGroup}" in groups ? "Admin" : ("${cfg.ldap.userGroup}" in groups ? "Editor" : "Invalid")''; }; shb.authelia.extraOidcClaimsPolicies.${roleClaim} = { custom_claims = { "${roleClaim}" = { }; }; }; shb.authelia.extraOidcScopes."${roleClaim}" = { claims = [ "${roleClaim}" ]; }; services.grafana.settings."auth.generic_oauth" = { enabled = true; name = "Authelia"; icon = "signin"; client_id = cfg.sso.clientID; client_secret = "$__file{${cfg.sso.sharedSecret.result.path}}"; scopes = oauthScopes; empty_scopes = false; allow_sign_up = true; auto_login = true; auth_url = "${cfg.sso.authEndpoint}/api/oidc/authorization"; token_url = "${cfg.sso.authEndpoint}/api/oidc/token"; # use_refresh_token = true; ? # https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/generic-oauth/#configure-generic-oauth-authentication-client-using-the-grafana-configuration-file api_url = "${cfg.sso.authEndpoint}/api/oidc/userinfo"; login_attribute_path = "preferred_username"; groups_attribute_path = "groups"; name_attribute_path = "name"; use_pkce = true; allow_assign_grafana_admin = true; skip_org_role_sync = false; role_attribute_path = roleClaim; role_attribute_strict = true; }; shb.authelia.oidcClients = [ { client_id = cfg.sso.clientID; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; claims_policy = "${roleClaim}"; scopes = oauthScopes; authorization_policy = cfg.sso.authorization_policy; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/login/generic_oauth" ]; require_pkce = true; pkce_challenge_method = "S256"; response_types = [ "code" ]; token_endpoint_auth_method = "client_secret_basic"; } ]; }) (lib.mkIf (cfg.enable && cfg.scrutiny.enable) { services.scrutiny = { enable = true; openFirewall = false; # This src includes Prometheus metrics exporter. package = pkgs.scrutiny.overrideAttrs ({ src = pkgs.fetchFromGitHub { owner = "ibizaman"; repo = "scrutiny"; rev = "7ff9a0530d3e54dd1323c2de34f32be330bfb48c"; hash = "sha256-dE4HuZzaGZKBEkzXwBLQL3h+D55tJMm/EOTpr3wqGAI="; }; vendorHash = "sha256-j3aGTeHNTr/FoVfFLwASkS96Ks0B/Ka9hPuLAKGZECs="; }); settings = { web = { metrics.enabled = true; # Enables Prometheus exporter listenHost = "127.0.0.1"; }; }; collector = { enable = true; }; }; services.prometheus.scrapeConfigs = [ { job_name = "scrutiny"; metrics_path = "/api/metrics"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}" ]; labels = commonLabels; } ]; } ]; shb.monitoring.dashboards = [ ./monitoring/dashboards/Health.json ]; shb.nginx.vhosts = lib.mkIf (cfg.scrutiny.subdomain != null) [ ( { inherit (cfg) domain ssl; subdomain = cfg.scrutiny.subdomain; upstream = "http://127.0.0.1:${toString config.services.scrutiny.settings.web.listen.port}"; autheliaRules = lib.optionals (cfg.sso.enable) [ { domain = "${cfg.subdomain}.${cfg.domain}"; policy = cfg.sso.authorization_policy; subject = [ "group:${cfg.ldap.userGroup}" "group:${cfg.ldap.adminGroup}" ]; } ]; } // lib.optionalAttrs cfg.sso.enable { inherit (cfg.sso) authEndpoint; } ) ]; }) ]; } ================================================ FILE: modules/blocks/nginx/docs/default.md ================================================ # Nginx Block {#blocks-nginx} Defined in [`/modules/blocks/nginx.nix`](@REPO@/modules/blocks/nginx.nix). This block sets up a [Nginx](https://nginx.org/) instance. It complements the upstream nixpkgs with some authentication and debugging improvements as shows in the Usage section. ## Usage {#blocks-nginx-usage} ### Access Logging {#blocks-nginx-usage-accesslog} JSON access logging is enabled with the [`shb.nginx.accessLog`](#blocks-nginx-options-shb.nginx.accessLog) option: ```nix { shb.nginx.accessLog = true; } ``` Looking at the systemd logs (`journalctl -fu nginx`) will show for example: ```json nginx[969]: server nginx: { "remote_addr":"192.168.1.1", "remote_user":"-", "time_local":"29/Dec/2025:14:22:41 +0000", "request":"POST /api/firstfactor HTTP/2.0", "request_length":"264", "server_name":"auth_example_com", "status":"200", "bytes_sent":"855", "body_bytes_sent":"60", "referrer":"-", "user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0", "gzip_ration":"-", "post":"{\x22username\x22:\x22charlie\x22,\x22password\x22:\x22CharliePassword\x22,\x22keepMeLoggedIn\x22:false,\x22targetURL\x22:\x22https://f.example.com/\x22,\x22requestMethod\x22:null}", "upstream_addr":"127.0.0.1:9091", "upstream_status":"200", "request_time":"0.873", "upstream_response_time":"0.873", "upstream_connect_time":"0.001", "upstream_header_time":"0.872" } ``` This _will_ log the body of POST queries so it should only be enabled for debug logging. ### Debug Logging {#blocks-nginx-usage-debuglog} Debug logging is enabled with the [`shb.nginx.debugLog`](#blocks-nginx-options-shb.nginx.debugLog) option: ```nix { shb.nginx.debugLog = true; } ``` If enabled, it sets: ``` error_log stderr warn; ``` ### Virtual Host Upstream Proxy {#blocks-nginx-usage-upstream} Easy upstream proxy setup is done with the [`shb.nginx.vhosts.*.upstream`](#blocks-nginx-options-shb.nginx.vhosts._.upstream) option: ```nix { shb.nginx.vhosts = [ { domain = "example.com"; subdomain = "mysubdomain"; upstream = "http://127.0.0.1:9090"; } ]; } ``` This will set also a few headers. Some are shown here and others please see in the [nginx](@REPO@/modules/blocks/nginx.nix) module: - `Host` = `$host`; - `X-Real-IP` = `$remote_addr`; - `X-Forwarded-For` = `$proxy_add_x_forwarded_for`; - `X-Forwarded-Proto` = `$scheme`; ### Virtual Host SSL Generator Contract Integration {#blocks-nginx-usage-ssl} This module integrates with the [SSL Generator Contract](./contracts-ssl.html) to setup HTTPs with the [`shb.nginx.vhosts.*.ssl`](#blocks-nginx-options-shb.nginx.vhosts._.ssl) option: ```nix { shb.nginx.vhosts = [ { domain = "example.com"; subdomain = "mysubdomain"; ssl = config.shb.certs.certs.letsencrypt.${domain};; } ]; shb.certs.certs.letsencrypt.${domain} = { inherit domain; }; } ``` ### Virtual Host SHB Forward Authentication {#blocks-nginx-usage-shbforwardauth} For services provided by SelfHostBlocks that do not handle [OIDC integration][OIDC], this block can provide [forward authentication][] which still allows the service to still be protected by an SSO server. [OIDC]: blocks-authelia.html#blocks-authelia-shb-oidc The user could still be required to authenticate to the service itself, although some services can automatically users authorized by Authelia. [forward authentication]: https://doc.traefik.io/traefik/middlewares/http/forwardauth/ Integrating with this block is done with the following code: ```nix shb..authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; ``` ### Virtual Host Forward Authentication {#blocks-nginx-usage-forwardauth} Forward authentication is when Nginx talks with the SSO service directly and the user is authenticated before reaching the upstream application. The SSO service responds with the username, group and more information about the user. This is then forwarded to the upstream application by Nginx. Note that _every_ request is authenticated this way with the SSO server so it involves more hops than a direct [OIDC integration](blocks-authelia.html#blocks-authelia-shb-oidc). ```nix { shb.nginx.vhosts = [ { domain = "example.com"; subdomain = "mysubdomain"; authEndpoint = "authelia.example.com"; autheliaRules = [ [ # Protect /admin endpoint with 2FA # and only allow access to admin users. { domain = "myapp.example.com"; policy = "two_factor"; subject = [ "group:service_admin" ]; resources = [ "^/admin" ]; } # Leave /api endpoint open - assumes an API key is used to protect it. { domain = "myapp.example.com"; policy = "bypass"; resources = [ "^/api" ]; }, # Protect rest of app with 1FA # and allow access to normal and admin users. { domain = "myapp.example.com"; policy = "one_factor"; subject = ["group:service_user"]; }, ] ]; } ]; } ``` If PHP is used with fastCGI, extra headers must be added by enabling the [`shb.nginx.vhosts.*.phpForwardAuth`](#blocks-nginx-options-shb.nginx.vhosts._.phpForwardAuth) option. ### Virtual Host Extra Config {#blocks-nginx-usage-extraconfig} To add extra configuration to a virtual host, use the [`shb.nginx.vhosts.*.extraConfig`](#blocks-nginx-options-shb.nginx.vhosts._.extraConfig) option. This can be used to add headers, for example: ```nix { shb.nginx.vhosts = [ { domain = "example.com"; subdomain = "mysubdomain"; extraConfig = '' add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; ''; } ]; } ``` ## Options Reference {#blocks-nginx-options} ```{=include=} options id-prefix: blocks-nginx-options- list-id: selfhostblocks-block-nginx-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/nginx.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.nginx; fqdn = c: "${c.subdomain}.${c.domain}"; vhostConfig = lib.types.submodule { options = { subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain which must be protected."; example = "subdomain"; }; domain = lib.mkOption { type = lib.types.str; description = "Domain of the subdomain."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; upstream = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "Upstream url to be protected."; default = null; example = "http://127.0.0.1:1234"; }; authEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "Optional auth endpoint for SSO."; default = null; example = "https://authelia.example.com"; }; autheliaRules = lib.mkOption { type = lib.types.listOf (lib.types.attrsOf lib.types.anything); default = [ ]; description = "Authelia rule configuration"; example = lib.literalExpression '' [ # Protect /admin endpoint with 2FA # and only allow access to admin users. { domain = "myapp.example.com"; policy = "two_factor"; subject = [ "group:service_admin" ]; resources = [ "^/admin" ]; } # Leave /api endpoint open - assumes an API key is used to protect it. { domain = "myapp.example.com"; policy = "bypass"; resources = [ "^/api" ]; }, # Protect rest of app with 1FA # and allow access to normal and admin users. { domain = "myapp.example.com"; policy = "one_factor"; subject = ["group:service_user"]; }, ] ''; }; phpForwardAuth = lib.mkOption { type = lib.types.bool; default = false; description = "Authelia rule configuration"; }; extraConfig = lib.mkOption { type = lib.types.lines; default = ""; description = "Extra config to add to the root / location. Strings separated by newlines."; }; }; }; in { imports = [ ./authelia.nix ]; options.shb.nginx = { accessLog = lib.mkOption { type = lib.types.bool; description = "Log all requests"; default = false; example = true; }; debugLog = lib.mkOption { type = lib.types.bool; description = "Verbose debug of internal. This will print what servers were matched and why."; default = false; example = true; }; vhosts = lib.mkOption { description = "Endpoints to be protected by authelia."; type = lib.types.listOf vhostConfig; default = [ ]; }; }; config = { networking.firewall.allowedTCPPorts = [ 80 443 ]; services.nginx.enable = true; services.nginx.logError = lib.mkIf cfg.debugLog "stderr warn"; services.nginx.appendHttpConfig = lib.mkIf cfg.accessLog '' log_format apm '{' '"remote_addr":"$remote_addr",' '"remote_user":"$remote_user",' '"time_local":"$time_local",' '"request":"$request",' '"request_length":"$request_length",' '"server_name":"$server_name",' '"status":"$status",' '"bytes_sent":"$bytes_sent",' '"body_bytes_sent":"$body_bytes_sent",' '"referrer":"$http_referrer",' '"user_agent":"$http_user_agent",' '"gzip_ration":"$gzip_ratio",' '"post":"$request_body",' '"upstream_addr":"$upstream_addr",' '"upstream_status":"$upstream_status",' '"request_time":"$request_time",' '"upstream_response_time":"$upstream_response_time",' '"upstream_connect_time":"$upstream_connect_time",' '"upstream_header_time":"$upstream_header_time"' '}'; access_log syslog:server=unix:/dev/log apm; ''; services.nginx.virtualHosts = let vhostCfg = c: { ${fqdn c} = { forceSSL = !(isNull c.ssl); sslCertificate = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.key; # Taken from https://github.com/authelia/authelia/issues/178 locations."/".extraConfig = lib.optionalString (c.upstream != null) '' add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff; add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; add_header X-Robots-Tag "noindex, nofollow, nosnippet, noarchive"; add_header X-Download-Options noopen; add_header X-Permitted-Cross-Domain-Policies none; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_cache_bypass $http_upgrade; proxy_pass ${c.upstream}; '' + c.extraConfig + lib.optionalString (c.authEndpoint != null) '' auth_request /authelia; auth_request_set $user $upstream_http_remote_user; auth_request_set $groups $upstream_http_remote_groups; proxy_set_header X-Forwarded-User $user; proxy_set_header X-Forwarded-Groups $groups; # TODO: Are those needed? # auth_request_set $name $upstream_http_remote_name; # auth_request_set $email $upstream_http_remote_email; # proxy_set_header Remote-Name $name; # proxy_set_header Remote-Email $email; # TODO: Would be nice to have this working, I think. # set $new_cookie $http_cookie; # if ($http_cookie ~ "(.*)(?:^|;)\s*example\.com\.session\.id=[^;]+(.*)") { # set $new_cookie $1$2; # } # proxy_set_header Cookie $new_cookie; auth_request_set $redirect $scheme://$http_host$request_uri; error_page 401 =302 ${c.authEndpoint}?rd=$redirect; error_page 403 = ${c.authEndpoint}/error/403; ''; locations."~ \\.php$".extraConfig = lib.mkIf (c.phpForwardAuth) '' fastcgi_param HTTP_X_FORWARDED_USER $user; fastcgi_param HTTP_X_FORWARDED_GROUPS $groups; ''; # Virtual endpoint created by nginx to forward auth requests. locations."/authelia".extraConfig = lib.mkIf (c.authEndpoint != null) '' internal; proxy_pass ${c.authEndpoint}/api/verify; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-URL $scheme://$host$request_uri; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Content-Length ""; proxy_pass_request_body off; # TODO: Would be nice to be able to enable this. # proxy_ssl_verify on; # proxy_ssl_trusted_certificate "/etc/ssl/certs/DST_Root_CA_X3.pem"; proxy_ssl_protocols TLSv1.2; proxy_ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH'; proxy_ssl_verify_depth 2; proxy_ssl_server_name on; ''; }; }; in lib.mkMerge (map vhostCfg cfg.vhosts); shb.authelia.rules = let authConfig = c: map (r: r // { domain = fqdn c; }) c.autheliaRules; in lib.flatten (map authConfig cfg.vhosts); security.acme.defaults.reloadServices = [ "nginx.service" ]; }; } ================================================ FILE: modules/blocks/postgresql/docs/default.md ================================================ # PostgreSQL Block {#blocks-postgresql} Defined in [`/modules/blocks/postgresql.nix`](@REPO@/modules/blocks/postgresql.nix). This block sets up a [PostgreSQL][] database. [postgresql]: https://www.postgresql.org/ Compared to the upstream nixpkgs module, this module also sets up: - Enabling TCP/IP login and also accepting password authentication from localhost with [`shb.postgresql.enableTCPIP`](#blocks-postgresql-options-shb.postgresql.enableTCPIP). - Enhance the `ensure*` upstream option by setting up a database's password from a password file with [`shb.postgresql.ensures`](#blocks-postgresql-options-shb.postgresql.ensures). - Debug logging with `auto_explain` and `pg_stat_statements` with [`shb.postgresql.debug`](#blocks-postgresql-options-shb.postgresql.debug). ## Usage {#blocks-postgresql-usage} ### Ensure User and Database {#blocks-postgresql-ensures} Ensure a database and user exists: ```nix shb.postgresql.ensures = [ { username = "firefly-iii"; database = "firefly-iii"; } ]; ``` Also set up the database password from a file path: ```nix shb.postgresql.ensures = [ { username = "firefly-iii"; database = "firefly-iii"; passwordFile = "/run/secrets/firefly-iii_db_password"; } ]; ``` ### Database Backup Requester Contracts {#blocks-postgresql-contract-databasebackup} This block can be backed up using the [database backup](contracts-databasebackup.html) contract. Contract integration tests are defined in [`/test/contracts/databasebackup.nix`](@REPO@/test/contracts/databasebackup.nix). #### Backing up All Databases {#blocks-postgresql-contract-databasebackup-all} ```nix { my.backup.provider."postgresql" = { request = config.shb.postgresql.databasebackup; settings = { // Specific options for the backup provider. }; }; } ``` ## Tests {#blocks-postgresql-tests} Specific integration tests are defined in [`/test/blocks/postgresql.nix`](@REPO@/test/blocks/postgresql.nix). ## Options Reference {#blocks-postgresql-options} ```{=include=} options id-prefix: blocks-postgresql-options- list-id: selfhostblocks-block-postgresql-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/postgresql.nix ================================================ { config, lib, pkgs, shb, ... }: let cfg = config.shb.postgresql; upgrade-script = old: new: let oldStr = builtins.toString old; newStr = builtins.toString new; oldPkg = pkgs.${"postgresql_${oldStr}"}; newPkg = pkgs.${"postgresql_${newStr}"}; in pkgs.writeScriptBin "upgrade-pg-cluster-${oldStr}-${newStr}" '' set -eux # XXX it's perhaps advisable to stop all services that depend on postgresql systemctl stop postgresql export NEWDATA="/var/lib/postgresql/${newPkg.psqlSchema}" export NEWBIN="${newPkg}/bin" export OLDDATA="/var/lib/postgresql/${oldPkg.psqlSchema}" export OLDBIN="${oldPkg}/bin" install -d -m 0700 -o postgres -g postgres "$NEWDATA" cd "$NEWDATA" sudo -u postgres $NEWBIN/initdb -D "$NEWDATA" sudo -u postgres $NEWBIN/pg_upgrade \ --old-datadir "$OLDDATA" --new-datadir "$NEWDATA" \ --old-bindir $OLDBIN --new-bindir $NEWBIN \ "$@" ''; in { imports = [ ../../lib/module.nix ]; options.shb.postgresql = { debug = lib.mkOption { type = lib.types.bool; description = '' Enable debugging options. Currently enables shared_preload_libraries = "auto_explain, pg_stat_statements" See https://www.postgresql.org/docs/current/pgstatstatements.html''; default = false; }; enableTCPIP = lib.mkOption { type = lib.types.bool; description = "Enable TCP/IP connection on given port."; default = false; }; databasebackup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.databasebackup.mkRequester { user = "postgres"; backupName = "postgres.sql"; backupCmd = '' ${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable ''; restoreCmd = '' ${pkgs.gzip}/bin/gunzip | ${pkgs.postgresql}/bin/psql postgres ''; }; }; }; ensures = lib.mkOption { description = "List of username, database and/or passwords that should be created."; type = lib.types.listOf ( lib.types.submodule { options = { username = lib.mkOption { type = lib.types.str; description = "Postgres user name."; }; database = lib.mkOption { type = lib.types.str; description = "Postgres database."; }; passwordFile = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "Optional password file for the postgres user. If not given, only peer auth is accepted for this user, otherwise password auth is allowed."; default = null; example = "/run/secrets/postgresql/password"; }; }; } ); default = [ ]; }; }; config = let commonConfig = { systemd.services.postgresql.serviceConfig.Restart = "always"; services.postgresql.settings = { }; }; tcpConfig = { services.postgresql.enableTCPIP = true; services.postgresql.authentication = lib.mkOverride 10 '' #type database DBuser origin-address auth-method local all all peer # ipv4 host all all 127.0.0.1/32 password # ipv6 host all all ::1/128 password ''; }; dbConfig = ensureCfgs: { services.postgresql.enable = lib.mkDefault ((builtins.length ensureCfgs) > 0); services.postgresql.ensureDatabases = map ({ database, ... }: database) ensureCfgs; services.postgresql.ensureUsers = map ( { username, database, ... }: { name = username; ensureDBOwnership = true; ensureClauses.login = true; } ) ensureCfgs; }; pwdConfig = ensureCfgs: { systemd.services.postgresql-setup.script = lib.mkAfter ( let prefix = '' psql -tA <<'EOF' DO $$ DECLARE password TEXT; BEGIN ''; suffix = '' END $$; EOF ''; exec = { username, passwordFile, ... }: '' password := trim(both from replace(pg_read_file('${passwordFile}'), E'\n', ''')); EXECUTE format('ALTER ROLE "${username}" WITH PASSWORD '''%s''';', password); ''; cfgsWithPasswords = builtins.filter (cfg: cfg.passwordFile != null) ensureCfgs; in if (builtins.length cfgsWithPasswords) == 0 then "" else prefix + (lib.concatStrings (map exec cfgsWithPasswords)) + suffix ); }; debugConfig = enableDebug: lib.mkIf enableDebug { services.postgresql.settings.shared_preload_libraries = "auto_explain, pg_stat_statements"; }; in lib.mkMerge ([ commonConfig (dbConfig cfg.ensures) (pwdConfig cfg.ensures) (lib.mkIf cfg.enableTCPIP tcpConfig) (debugConfig cfg.debug) { environment.systemPackages = lib.mkIf config.services.postgresql.enable [ (upgrade-script 15 16) (upgrade-script 16 17) ]; } ]); } ================================================ FILE: modules/blocks/restic/docs/default.md ================================================ # Restic Block {#blocks-restic} Defined in [`/modules/blocks/restic.nix`](@REPO@/modules/blocks/restic.nix). This block sets up a backup job using [Restic][]. [restic]: https://restic.net/ ## Provider Contracts {#blocks-restic-contract-provider} This block provides the following contracts: - [backup contract](contracts-backup.html) under the [`shb.restic.instances`][instances] option. It is tested with [contract tests][backup contract tests]. - [database backup contract](contracts-databasebackup.html) under the [`shb.restic.databases`][databases] option. It is tested with [contract tests][database backup contract tests]. [instances]: #blocks-restic-options-shb.restic.instances [databases]: #blocks-restic-options-shb.restic.databases [backup contract tests]: @REPO@/test/contracts/backup.nix [database backup contract tests]: @REPO@/test/contracts/databasebackup.nix As requested by those two contracts, when setting up a backup with Restic, a backup Systemd service and a [restore script](#blocks-restic-maintenance) are provided. ## Usage {#blocks-restic-usage} The following examples assume usage of the [sops block][] to provide secrets although any blocks providing the [secrets contract][] works too. [sops block]: ./blocks-sops.html [secrets contract]: ./contracts-secrets.html ### One folder backed up manually {#blocks-restic-usage-provider-manual} The following snippet shows how to configure the backup of 1 folder to 1 repository. We assume that the folder `/var/lib/myfolder` of the service `myservice` must be backed up. ```nix shb.restic.instances."myservice" = { request = { user = "myservice"; sourceDirectories = [ "/var/lib/myfolder" ]; }; settings = { enable = true; passphrase.result = config.shb.sops.secret."passphrase".result; repository = { path = "/srv/backups/myservice"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; }; retention = { keep_within = "1d"; keep_hourly = 24; keep_daily = 7; keep_weekly = 4; keep_monthly = 6; }; }; }; shb.sops.secret."passphrase".request = config.shb.restic.instances."myservice".settings.passphrase.request; ``` ### One folder backed up with contract {#blocks-restic-usage-provider-contract} With the same example as before but assuming the `myservice` service has a `myservice.backup` option that is a requester for the backup contract, the snippet above becomes: ```nix shb.restic.instances."myservice" = { request = config.myservice.backup.request; settings = { enable = true; passphrase.result = config.shb.sops.secret."passphrase".result; repository = { path = "/srv/backups/myservice"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; }; retention = { keep_within = "1d"; keep_hourly = 24; keep_daily = 7; keep_weekly = 4; keep_monthly = 6; }; }; }; shb.sops.secret."passphrase".request = config.shb.restic.instances."myservice".settings.passphrase.request; ``` ### One folder backed up to S3 {#blocks-restic-usage-provider-remote} Here we will only highlight the differences with the previous configuration. This assumes you have access to such a remote S3 store, for example by using [Backblaze](https://www.backblaze.com/). ```diff shb.test.backup.instances.myservice = { repository = { - path = "/srv/pool1/backups/myfolder"; + path = "s3:s3.us-west-000.backblazeb2.com/backups/myfolder"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; + extraSecrets = { + AWS_ACCESS_KEY_ID.source=""; + AWS_SECRET_ACCESS_KEY.source=""; + }; }; } ``` ### Multiple directories to multiple destinations {#blocks-restic-usage-multiple} The following snippet shows how to configure backup of any number of folders to 3 repositories, each happening at different times to avoid I/O contention. We will also make sure to be able to re-use as much as the configuration as possible. A few assumptions: - 2 hard drive pools used for backup are mounted respectively on `/srv/pool1` and `/srv/pool2`. - You have a backblaze account. First, let's define a variable to hold all the repositories we want to back up to: ```nix repos = [ { path = "/srv/pool1/backups"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "3h"; }; } { path = "/srv/pool2/backups"; timerConfig = { OnCalendar = "08:00:00"; RandomizedDelaySec = "3h"; }; } { path = "s3:s3.us-west-000.backblazeb2.com/backups"; timerConfig = { OnCalendar = "16:00:00"; RandomizedDelaySec = "3h"; }; } ]; ``` Compared to the previous examples, we do not include the name of what we will back up in the repository paths. Now, let's define a function to create a backup configuration. It will take a list of repositories, a name identifying the backup and a list of folders to back up. ```nix backupcfg = repositories: name: sourceDirectories { enable = true; backend = "restic"; keySopsFile = ../secrets/backup.yaml; repositories = builtins.map (r: { path = "${r.path}/${name}"; inherit (r) timerConfig; }) repositories; inherit sourceDirectories; retention = { keep_within = "1d"; keep_hourly = 24; keep_daily = 7; keep_weekly = 4; keep_monthly = 6; }; environmentFile = true; }; ``` Now, we can define multiple backup jobs to backup different folders: ```nix shb.test.backup.instances.myfolder1 = backupcfg repos ["/var/lib/myfolder1"]; shb.test.backup.instances.myfolder2 = backupcfg repos ["/var/lib/myfolder2"]; ``` The difference between the above snippet and putting all the folders into one configuration (shown below) is the former splits the backups into sub-folders on the repositories. ```nix shb.test.backup.instances.all = backupcfg repos ["/var/lib/myfolder1" "/var/lib/myfolder2"]; ``` ## Monitoring {#blocks-restic-monitoring} A generic dashboard for all backup solutions is provided. See [Backups Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-backup) section in the monitoring chapter. ## Maintenance {#blocks-restic-maintenance} One command-line helper is provided per backup instance and repository pair to automatically supply the needed secrets. The restore script has all the secrets needed to access the repo, it will run `sudo` automatically and the user running it needs to have correct permissions for privilege escalation In the [multiple directories example](#blocks-restic-usage-multiple) above, the following 6 helpers are provided in the `$PATH`: ```bash restic-myfolder1_srv_pool1_backups restic-myfolder1_srv_pool2_backups restic-myfolder1_s3_s3.us-west-000.backblazeb2.com_backups restic-myfolder2_srv_pool1_backups restic-myfolder2_srv_pool2_backups restic-myfolder2_s3_s3.us-west-000.backblazeb2.com_backups ``` Discovering those is easy thanks to tab-completion. One can then restore a backup from a given repository with: ```bash restic-myfolder1_srv_pool1_backups restore latest ``` ### Troubleshooting {#blocks-restic-maintenance-troubleshooting} In case something bad happens with a backup, the [official documentation](https://restic.readthedocs.io/en/stable/077_troubleshooting.html) has a lot of tips. ## Tests {#blocks-restic-tests} Specific integration tests are defined in [`/test/blocks/restic.nix`](@REPO@/test/blocks/restic.nix). ## Options Reference {#blocks-restic-options} ```{=include=} options id-prefix: blocks-restic-options- list-id: selfhostblocks-block-restic-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/restic/dummyModule.nix ================================================ { lib, ... }: { } ================================================ FILE: modules/blocks/restic.nix ================================================ { config, pkgs, lib, shb, utils, ... }: let cfg = config.shb.restic; inherit (lib) concatStringsSep filterAttrs flatten literalExpression optionals listToAttrs mapAttrsToList mkEnableOption mkOption mkMerge ; inherit (lib) hasPrefix mkIf nameValuePair optionalAttrs removePrefix ; inherit (lib.types) attrsOf enum int ints oneOf nonEmptyStr nullOr str submodule ; commonOptions = { name, prefix, config, ... }: { enable = mkEnableOption '' SelfHostBlocks' Restic block A disabled instance will not backup data anymore but still provides the helper tool to restore snapshots ''; passphrase = lib.mkOption { description = "Encryption key for the backup repository."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.request.user; ownerText = "[shb.restic.${prefix}..request.user](#blocks-restic-options-shb.restic.${prefix}._name_.request.user)"; restartUnits = [ "${fullName name config.settings.repository}.service" ]; restartUnitsText = "[ [shb.restic.${prefix}..settings.repository](#blocks-restic-options-shb.restic.${prefix}._name_.settings.repository) ]"; }; }; }; repository = mkOption { description = "Repositories to back this instance to."; type = submodule { options = { path = mkOption { type = str; description = "Repository location"; }; secrets = mkOption { type = attrsOf shb.secretFileType; default = { }; description = '' Secrets needed to access the repository where the backups will be stored. See [s3 config](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#amazon-s3) for an example and [list](https://restic.readthedocs.io/en/latest/040_backup.html#environment-variables) for the list of all secrets. ''; example = literalExpression '' { AWS_ACCESS_KEY_ID.source = ; AWS_SECRET_ACCESS_KEY.source = ; } ''; }; timerConfig = mkOption { type = attrsOf utils.systemdUtils.unitOptions.unitOption; default = { OnCalendar = "daily"; Persistent = true; }; description = "When to run the backup. See {manpage}`systemd.timer(5)` for details."; example = { OnCalendar = "00:05"; RandomizedDelaySec = "5h"; Persistent = true; }; }; }; }; }; retention = mkOption { description = "For how long to keep backup files."; type = attrsOf (oneOf [ int nonEmptyStr ]); default = { keep_within = "1d"; keep_hourly = 24; keep_daily = 7; keep_weekly = 4; keep_monthly = 6; }; }; limitUploadKiBs = mkOption { type = nullOr int; description = "Limit upload bandwidth to the given KiB/s amount."; default = null; example = 8000; }; limitDownloadKiBs = mkOption { type = nullOr int; description = "Limit download bandwidth to the given KiB/s amount."; default = null; example = 8000; }; }; repoSlugName = name: builtins.replaceStrings [ "/" ":" ] [ "_" "_" ] (removePrefix "/" name); fullName = name: repository: "restic-backups-${name}_${repoSlugName repository.path}"; in { imports = [ ../../lib/module.nix ../blocks/monitoring.nix ]; options.shb.restic = { enableDashboard = lib.mkEnableOption "the Backups SHB dashboard" // { default = true; }; instances = mkOption { description = "Files to backup following the [backup contract](./shb.contracts-backup.html)."; default = { }; type = attrsOf ( submodule ( { name, config, ... }: { options = shb.contracts.backup.mkProvider { settings = mkOption { description = '' Settings specific to the Restic provider. ''; type = submodule { options = commonOptions { inherit name config; prefix = "instances"; }; }; }; resultCfg = { restoreScript = fullName name config.settings.repository; restoreScriptText = "${fullName "" { path = "path/to/repository"; }}"; backupService = "${fullName name config.settings.repository}.service"; backupServiceText = "${fullName "" { path = "path/to/repository"; }}.service"; }; }; } ) ); }; databases = mkOption { description = "Databases to backup following the [database backup contract](./shb.contracts-databasebackup.html)."; default = { }; type = attrsOf ( submodule ( { name, config, ... }: { options = shb.contracts.databasebackup.mkProvider { settings = mkOption { description = '' Settings specific to the Restic provider. ''; type = submodule { options = commonOptions { inherit name config; prefix = "databases"; }; }; }; resultCfg = { restoreScript = fullName name config.settings.repository; restoreScriptText = "${fullName "" { path = "path/to/repository"; }}"; backupService = "${fullName name config.settings.repository}.service"; backupServiceText = "${fullName "" { path = "path/to/repository"; }}.service"; }; }; } ) ); }; # Taken from https://github.com/HubbeKing/restic-kubernetes/blob/73bfbdb0ba76939a4c52173fa2dbd52070710008/README.md?plain=1#L23 performance = mkOption { description = "Reduce performance impact of backup jobs."; default = { }; type = submodule { options = { niceness = mkOption { type = ints.between (-20) 19; description = "nice priority adjustment, defaults to 15 for ~20% CPU time of normal-priority process"; default = 15; }; ioSchedulingClass = mkOption { type = enum [ "idle" "best-effort" "realtime" ]; description = "ionice scheduling class, defaults to best-effort IO. Only used for `restic backup`, `restic forget` and `restic check` commands."; default = "best-effort"; }; ioPriority = mkOption { type = nullOr (ints.between 0 7); description = "ionice priority, defaults to 7 for lowest priority IO. Only used for `restic backup`, `restic forget` and `restic check` commands."; default = 7; }; }; }; }; }; config = mkIf (cfg.instances != { } || cfg.databases != { }) ( let enabledInstances = filterAttrs (k: i: i.settings.enable) cfg.instances; enabledDatabases = filterAttrs (k: i: i.settings.enable) cfg.databases; in mkMerge [ { environment.systemPackages = optionals (enabledInstances != { } || enabledDatabases != { }) [ pkgs.restic ]; } { # Create repository if it is a local path. systemd.tmpfiles.rules = let mkSettings = name: instance: optionals (hasPrefix "/" instance.settings.repository.path) [ "d '${instance.settings.repository.path}' 0750 ${instance.request.user} root - -" ]; in flatten (mapAttrsToList mkSettings (cfg.instances // cfg.databases)); } { services.restic.backups = let mkSettings = name: instance: { "${name}_${repoSlugName instance.settings.repository.path}" = { inherit (instance.request) user; repository = instance.settings.repository.path; paths = instance.request.sourceDirectories; passwordFile = toString instance.settings.passphrase.result.path; initialize = true; inherit (instance.settings.repository) timerConfig; pruneOpts = mapAttrsToList ( name: value: "--${builtins.replaceStrings [ "_" ] [ "-" ] name} ${builtins.toString value}" ) instance.settings.retention; backupPrepareCommand = concatStringsSep "\n" instance.request.hooks.beforeBackup; backupCleanupCommand = concatStringsSep "\n" instance.request.hooks.afterBackup; extraBackupArgs = (optionals (instance.settings.limitUploadKiBs != null) [ "--limit-upload=${toString instance.settings.limitUploadKiBs}" ]) ++ (optionals (instance.settings.limitDownloadKiBs != null) [ "--limit-download=${toString instance.settings.limitDownloadKiBs}" ]); } // optionalAttrs (builtins.length instance.request.excludePatterns > 0) { exclude = instance.request.excludePatterns; }; }; in mkMerge (flatten (mapAttrsToList mkSettings enabledInstances)); } { services.restic.backups = let mkSettings = name: instance: { "${name}_${repoSlugName instance.settings.repository.path}" = { inherit (instance.request) user; repository = instance.settings.repository.path; dynamicFilesFrom = "echo"; passwordFile = toString instance.settings.passphrase.result.path; initialize = true; inherit (instance.settings.repository) timerConfig; pruneOpts = mapAttrsToList ( name: value: "--${builtins.replaceStrings [ "_" ] [ "-" ] name} ${builtins.toString value}" ) instance.settings.retention; extraBackupArgs = (optionals (instance.settings.limitUploadKiBs != null) [ "--limit-upload=${toString instance.settings.limitUploadKiBs}" ]) ++ (optionals (instance.settings.limitDownloadKiBs != null) [ "--limit-download=${toString instance.settings.limitDownloadKiBs}" ]) ++ ( let cmd = pkgs.writeShellScriptBin "dump.sh" instance.request.backupCmd; in [ "--stdin-filename ${instance.request.backupName} --stdin-from-command -- ${cmd}/bin/dump.sh" ] ); }; }; in mkMerge (flatten (mapAttrsToList mkSettings enabledDatabases)); } { systemd.services = let mkSettings = name: instance: let serviceName = fullName name instance.settings.repository; in { ${serviceName} = mkMerge [ { serviceConfig = { Nice = cfg.performance.niceness; IOSchedulingClass = cfg.performance.ioSchedulingClass; IOSchedulingPriority = cfg.performance.ioPriority; # BindReadOnlyPaths = instance.sourceDirectories; }; } (optionalAttrs (instance.settings.repository.secrets != { }) { serviceConfig.EnvironmentFile = [ "/run/secrets_restic/${serviceName}" ]; after = [ "${serviceName}-pre.service" ]; requires = [ "${serviceName}-pre.service" ]; }) ]; "${serviceName}-pre" = mkIf (instance.settings.repository.secrets != { }) ( let script = shb.genConfigOutOfBandSystemd { config = instance.settings.repository.secrets; configLocation = "/run/secrets_restic/${serviceName}"; generator = shb.toEnvVar; user = instance.request.user; }; in { script = script.preStart; serviceConfig.Type = "oneshot"; serviceConfig.LoadCredential = script.loadCredentials; } ); }; in mkMerge (flatten (mapAttrsToList mkSettings (enabledInstances // enabledDatabases))); } { systemd.services = let mkEnv = name: instance: nameValuePair "${fullName name instance.settings.repository}_restore_gen" { enable = true; wantedBy = [ "multi-user.target" ]; serviceConfig.Type = "oneshot"; script = ( shb.replaceSecrets { userConfig = instance.settings.repository.secrets // { RESTIC_PASSWORD_FILE = toString instance.settings.passphrase.result.path; RESTIC_REPOSITORY = instance.settings.repository.path; }; resultPath = "/run/secrets_restic_env/${fullName name instance.settings.repository}"; generator = shb.toEnvVar; user = instance.request.user; } ); }; in listToAttrs (flatten (mapAttrsToList mkEnv (cfg.instances // cfg.databases))); } { environment.systemPackages = let mkResticBinary = name: instance: pkgs.writeShellApplication { name = fullName name instance.settings.repository; text = '' usage() { echo "$0 restore latest" } if ! [ "$1" = "restore" ]; then usage exit 1 fi shift if ! [ "$1" = "latest" ]; then usage exit 1 fi shift sudocmd() { sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} "$@" } set -a # shellcheck disable=SC1090 source <(sudocmd cat "/run/secrets_restic_env/${fullName name instance.settings.repository}") set +a echo "Will restore archive 'latest'" sudocmd ${pkgs.restic}/bin/restic restore latest --target / ''; }; in flatten (mapAttrsToList mkResticBinary cfg.instances); } { environment.systemPackages = let mkResticBinary = name: instance: pkgs.writeShellApplication { name = fullName name instance.settings.repository; text = '' usage() { echo "$0 restore latest" } if ! [ "$1" = "restore" ]; then usage exit 1 fi shift if ! [ "$1" = "latest" ]; then usage exit 1 fi shift sudocmd() { sudo --preserve-env=RESTIC_REPOSITORY,RESTIC_PASSWORD_FILE -u ${instance.request.user} "$@" } set -a # shellcheck disable=SC1090 source <(sudocmd cat "/run/secrets_restic_env/${fullName name instance.settings.repository}") set +a echo "Will restore archive 'latest'" sudocmd sh -c "${pkgs.restic}/bin/restic dump latest ${instance.request.backupName} | ${instance.request.restoreCmd}" ''; }; in flatten (mapAttrsToList mkResticBinary cfg.databases); } (lib.mkIf (cfg.enableDashboard && (cfg.instances != { } || cfg.databases != { })) { shb.monitoring.dashboards = [ ./backup/dashboard/Backups.json ]; }) ] ); } ================================================ FILE: modules/blocks/sops/docs/default.md ================================================ # SOPS Block {#blocks-sops} Defined in [`/modules/blocks/sops.nix`](@REPO@/modules/blocks/sops.nix). This block sets up a [sops-nix][] secret. It is only a small layer on top of `sops-nix` options to adapt it to the [secret contract](./contracts-secret.html). [sops-nix]: https://github.com/Mic92/sops-nix ## Provider Contracts {#blocks-sops-contract-provider} This block provides the following contracts: - [secret contract][] under the [`shb.sops.secret`][secret] option. It is not yet tested with [contract tests][secret contract tests] but it is used extensively on several machines. [secret]: #blocks-sops-options-shb.sops.secret [secret contract]: contracts-secret.html [secret contract tests]: @REPO@/test/contracts/secret.nix As requested by the contract, when asking for a secret with the `shb.sops` module, the path where the secret will be located can be found under the [`shb.sops.secret..result`][result] option. [result]: #blocks-sops-options-shb.sops.secret._name_.result ## Usage {#blocks-sops-usage} First, a file with encrypted secrets must be created by following the [secrets setup section](usage.html#usage-secrets). ### With Requester Module {#blocks-sops-usage-requester} This example shows how to use this sops block to fulfill the request of a module using the [secret contract][] under the option `services.mymodule.mysecret`. ```nix shb.sops.secret."mymodule/mysecret".request = config.services.mymodule.mysecret.request; services.mymodule.mysecret.result = config.shb.sops.secret."mymodule/mysecret".result; ``` ### Manual Module {#blocks-sops-usage-manual} The provider module can be used on its own, without a requester module: ```nix shb.sops.secret."mymodule/mysecret".request = { mode = "0400"; owner = "owner"; }; services.mymodule.mysecret.path = config.sops.secret."mymodule/mysecret".result.path; ``` ## Options Reference {#blocks-sops-options} ```{=include=} options id-prefix: blocks-sops-options- list-id: selfhostblocks-block-sops-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/sops.nix ================================================ { config, lib, shb, ... }: let inherit (lib) mapAttrs mkOption; inherit (lib.types) attrsOf anything submodule; cfg = config.shb.sops; in { imports = [ ../../lib/module.nix ]; options.shb.sops = { secret = mkOption { description = "Secret following the [secret contract](./contracts-secret.html)."; default = { }; type = attrsOf ( submodule ( { name, options, ... }: { options = shb.contracts.secret.mkProvider { settings = mkOption { description = '' Settings specific to the Sops provider. This is a passthrough option to set [sops-nix options](https://github.com/Mic92/sops-nix/blob/master/modules/sops/default.nix). Note though that the `mode`, `owner`, `group`, and `restartUnits` are managed by the [shb.sops.secret..request](#blocks-sops-options-shb.sops.secret._name_.request) option. ''; type = attrsOf anything; default = { }; }; resultCfg = { path = "/run/secrets/${name}"; pathText = "/run/secrets/"; }; }; } ) ); }; }; config = { sops.secrets = let mkSecret = n: secretCfg: secretCfg.request // secretCfg.settings; in mapAttrs mkSecret cfg.secret; }; } ================================================ FILE: modules/blocks/ssl/dashboard/SSL.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": 16, "links": [], "panels": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMin": 0, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "line+area" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": 0 }, { "color": "transparent", "value": 604808 } ] }, "unit": "s" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "id": 3, "options": { "legend": { "calcs": [ "lastNotNull" ], "displayMode": "table", "placement": "bottom", "showLegend": true, "sortBy": "Last *", "sortDesc": false }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "min by(exported_hostname, subject, path) (ssl_certificate_expiry_seconds{subject=~\"CN=$job\"})", "legendFormat": "{{exported_hostname}}: {{subject}} {{path}}", "range": true, "refId": "A" } ], "title": "Certificate Remaining Validity", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineStyle": { "fill": "solid" }, "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": 0 } ] }, "unit": "dateTimeFromNow" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, "id": 5, "options": { "legend": { "calcs": [ "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true, "sortBy": "Last *", "sortDesc": true, "width": 300 }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "editorMode": "code", "exemplar": false, "expr": "systemd_timer_next_trigger_seconds{name=~\"acme-renew-$job.timer\"} * 1000", "format": "time_series", "instant": false, "legendFormat": "{{name}}", "range": true, "refId": "A" } ], "title": "Schedule", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "fixedColor": "green", "mode": "fixed" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "footer": { "reducers": [] }, "inspect": false }, "decimals": 0, "mappings": [], "noValue": "0", "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "green", "value": 80 } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "% failed" }, "properties": [ { "id": "unit", "value": "percentunit" }, { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } }, { "id": "color", "value": { "mode": "continuous-GrYlRd" } }, { "id": "max", "value": 1 } ] }, { "matcher": { "id": "byName", "options": "total" }, "properties": [ { "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "color-background" } }, { "id": "color", "value": { "mode": "thresholds" } }, { "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "red", "value": 0 }, { "color": "transparent", "value": 1 } ] } } ] }, { "matcher": { "id": "byType", "options": "string" }, "properties": [ { "id": "custom.minWidth", "value": 150 } ] }, { "matcher": { "id": "byType", "options": "number" }, "properties": [ { "id": "custom.width", "value": 100 } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, "id": 4, "options": { "cellHeight": "sm", "enablePagination": true, "frozenColumns": {}, "showHeader": true, "sortBy": [] }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "increase(systemd_unit_state{name=~\"acme-(order-renew-)?[[job]].service\", state=\"activating\"}[7d])", "instant": true, "legendFormat": "__auto", "range": false, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "increase(systemd_unit_state{name=~\"acme-(order-renew-)?[[job]].service\", state=\"failed\"}[7d])", "hide": false, "instant": true, "legendFormat": "__auto", "range": false, "refId": "B" } ], "title": "Jobs in the Past Week", "transformations": [ { "id": "labelsToFields", "options": { "mode": "columns" } }, { "id": "merge", "options": {} }, { "id": "groupingToMatrix", "options": { "columnField": "state", "rowField": "name", "valueField": "Value" } }, { "id": "calculateField", "options": { "alias": "total", "binary": { "left": { "matcher": { "id": "byName", "options": "activating" } }, "operator": "+", "right": { "matcher": { "id": "byName", "options": "failed" } } }, "mode": "binary", "reduce": { "include": [ "activating", "failed" ], "reducer": "sum" } } }, { "id": "calculateField", "options": { "alias": "% failed", "binary": { "left": { "matcher": { "id": "byName", "options": "failed" } }, "operator": "/", "right": { "matcher": { "id": "byName", "options": "total" } } }, "mode": "binary", "reduce": { "reducer": "sum" } } }, { "id": "sortBy", "options": { "fields": {}, "sort": [ { "desc": true, "field": "total" } ] } }, { "id": "sortBy", "options": { "fields": {}, "sort": [ { "desc": true, "field": "failed" } ] } }, { "id": "organize", "options": { "excludeByName": {}, "includeByName": {}, "indexByName": {}, "renameByName": { "activating": "success", "name\\state": "Job" } } } ], "type": "table" }, { "description": "", "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 2, "w": 12, "x": 12, "y": 8 }, "id": 7, "options": { "code": { "language": "plaintext", "showLineNumbers": false, "showMiniMap": false }, "content": "If the log panel is empty, it may be because the amount of lines is too high. Try filtering a few jobs first.", "mode": "markdown" }, "pluginVersion": "12.2.0", "title": "", "type": "text" }, { "datasource": { "default": false, "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 14, "w": 12, "x": 12, "y": 10 }, "id": 8, "options": { "dedupStrategy": "none", "enableInfiniteScrolling": false, "enableLogDetails": false, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": true }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "direction": "backward", "editorMode": "code", "expr": "{unit=~\"acme-(order-renew-)?($job).service\"}", "queryType": "range", "refId": "A" } ], "title": "Logs - $job", "type": "logs" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "The job duration is not accurate. Jobs taking less than 15s to run will sometimes appear as taking 100s.", "fieldConfig": { "defaults": { "color": { "mode": "fixed" }, "custom": { "axisPlacement": "auto", "fillOpacity": 70, "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineWidth": 0, "spanNulls": false }, "mappings": [ { "options": { "1": { "color": "green", "index": 0, "text": "Running" } }, "type": "value" } ], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "#EAB839", "value": 0 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 }, "id": 6, "options": { "alignValue": "left", "legend": { "displayMode": "list", "placement": "bottom", "showLegend": false }, "mergeValues": true, "rowHeight": 0.9, "showValue": "never", "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.2.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "max(label_replace(systemd_unit_state{name=~\"acme-(order-renew-)?$job.service\", state=\"activating\"}, \"name\", \"$1\", \"name\", \"acme-order-renew-(.*).service\") > 0 or on(name) label_replace(clamp(systemd_timer_last_trigger_seconds{name=~\"acme-renew-$job.timer\"} - (systemd_timer_last_trigger_seconds{name=~\"acme-renew-$job.timer\"} offset 100s) > 0, 0, 1), \"name\", \"$1\", \"name\", \"acme-renew-(.*).timer\")) by (name)", "format": "time_series", "hide": false, "instant": false, "key": "Q-e1d5c07a-8dcc-4f34-aa5c-cdebcbdda322-0", "legendFormat": "{{name}}", "range": true, "refId": "A" } ], "title": "Job Runs", "type": "state-timeline" } ], "preload": false, "schemaVersion": 42, "tags": [], "templating": { "list": [ { "current": { "text": [ "All" ], "value": [ "$__all" ] }, "definition": "label_values(systemd_unit_state{name=~\"acme-renew-.*.timer\"},name)", "includeAll": true, "label": "Job", "multi": true, "name": "job", "options": [], "query": { "qryType": 1, "query": "label_values(systemd_unit_state{name=~\"acme-renew-.*.timer\"},name)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "/acme-renew-(?.*).timer/", "type": "query" } ] }, "time": { "from": "now-6h", "to": "now" }, "timepicker": {}, "timezone": "browser", "title": "SSL Certificates", "uid": "ae818js0bvw8wb", "version": 25 } ================================================ FILE: modules/blocks/ssl/docs/default.md ================================================ # SSL Generator Block {#block-ssl} This NixOS module is a block that implements the [SSL certificate generator](contracts-ssl.html) contract. It is implemented by: - [`shb.certs.cas.selfsigned`][10] and [`shb.certs.certs.selfsigned`][11]: Generates self-signed certificates, including self-signed CA thanks to the [certtool][1] package. - [`shb.certs.certs.letsencrypt`][12]: Requests certificates from [Let's Encrypt][2]. [1]: https://search.nixos.org/packages?channel=23.11&show=gnutls&from=0&size=50&sort=relevance&type=packages&query=certtool [2]: https://letsencrypt.org/ [10]: blocks-ssl.html#blocks-ssl-options-shb.certs.cas.selfsigned [11]: blocks-ssl.html#blocks-ssl-options-shb.certs.certs.selfsigned [12]: blocks-ssl.html#blocks-ssl-options-shb.certs.certs.letsencrypt ## Self-Signed Certificates {#block-ssl-impl-self-signed} Defined in [`/modules/blocks/ssl.nix`](@REPO@/modules/blocks/ssl.nix). To use self-signed certificates, we must first generate at least one Certificate Authority (CA): ```nix shb.certs.cas.selfsigned.myca = { name = "My CA"; }; ``` Every CA defined this way will be concatenated into the file `/etc/ssl/certs/ca-certificates.cert` which means those CAs and all certificates generated by those CAs will be automatically trusted. We can then generate one or more certificates signed by that CA: ```nix shb.certs.certs.selfsigned = { "example.com" = { ca = config.shb.certs.cas.selfsigned.myca; domain = "example.com"; group = "nginx"; reloadServices = [ "nginx.service" ]; }; "www.example.com" = { ca = config.shb.certs.cas.selfsigned.myca; domain = "www.example.com"; group = "nginx"; }; }; ``` The group has been chosen to be `nginx` to be consistent with the examples further down in this document. ## Let's Encrypt {#block-ssl-impl-lets-encrypt} Defined in [`/modules/blocks/ssl.nix`](@REPO@/modules/blocks/ssl.nix). We can ask Let's Encrypt to generate a certificate with: ```nix shb.certs.certs.letsencrypt."example.com" = { domain = "example.com"; group = "nginx"; reloadServices = [ "nginx.service" ]; dnsProvider = "linode"; adminEmail = "admin@example.com"; credentialsFile = /path/to/secret/file; additionalEnvironment = { LINODE_HTTP_TIMEOUT = "10"; LINODE_POLLING_INTERVAL = "10"; LINODE_PROPAGATION_TIMEOUT = "240"; }; }; ``` The credential file's content would be a key-value pair: ```yaml LINODE_TOKEN=XYZ... ``` If you use one subdomain per service, asking for certificates for a subdomain is done with: ```nix shb.certs.certs.letsencrypt."example.com".extraDomains = [ "nextcloud.${domain}" ]; ``` For other providers, see the [official instruction](https://go-acme.github.io/lego/dns/). ## Usage {#block-ssl-usage} To use either a self-signed certificates or a Let's Encrypt generated one, we can reference the path where the certificate and the private key are located: ```nix config.shb.certs.certs...paths.cert config.shb.certs.certs...paths.key config.shb.certs.certs...systemdService ``` For example: ```nix config.shb.certs.certs.selfsigned."example.com".paths.cert config.shb.certs.certs.selfsigned."example.com".paths.key config.shb.certs.certs.selfsigned."example.com".systemdService ``` The full CA bundle is generated by the following Systemd service, running after each individual generator finished: ```nix config.shb.certs.systemdService ``` See also the [SSL certificate generator usage](contracts-ssl.html#ssl-contract-usage) for a more detailed usage example. ## Monitoring {#blocks-ssl-monitoring} A dashboard for SSL certificates is provided. See [SSL Certificates Dashboard and Alert](blocks-monitoring.html#blocks-monitoring-ssl) section in the monitoring chapter. ## Debug {#block-ssl-debug} Each CA and Cert is generated by a systemd service whose name can be seen in the `systemdService` option. You can then see the latest errors messages using `journalctl`. ### Let's Encrypt debug {#blocks-ssl-debug-lets-encrypt} Since the SHB SSL block uses the [`security.acme`][] module under the hood, knowing how that one works can become required if something goes wrong. For each domain and subdomain, noted as `fqdn` hereunder, the following systemd timers and services are created: - `acme-renew-${fqdn}.timer` triggers the `acme-order-renew-${fqdn}.service` service every day. - `acme-${fqdn}.service` (re)generate the initial self-signed certificate, only if the following job never succeeded at least once yet. - `acme-order-renew-${fqdn}.service` asks for a new certificate only if the certificate will expire in the next 30 days. Has logic to only renew if the list of domains has not changed. Also, a global service named `acme-setup.service` is created [`security.acme`]: https://nixos.org/manual/nixos/stable/#module-security-acme ## Tests {#block-ssl-tests} The self-signed implementation is tested in [`/tests/vm/ssl.nix`](@REPO@/tests/vm/ssl.nix). ## Options Reference {#block-ssl-options} ```{=include=} options id-prefix: blocks-ssl-options- list-id: selfhostblocks-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/blocks/ssl.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.certs; inherit (builtins) dirOf; inherit (lib) flatten mapAttrsToList optionalAttrs optionals unique ; in { imports = [ ../../lib/module.nix ./monitoring.nix ]; options.shb.certs = { systemdService = lib.mkOption { description = '' Systemd oneshot service used to generate the Certificate Authority bundle. ''; type = lib.types.str; default = "shb-ca-bundle.service"; }; enableDashboard = lib.mkEnableOption "the SSL SHB dashboard" // { default = true; }; cas.selfsigned = lib.mkOption { description = "Generate a self-signed Certificate Authority."; default = { }; type = lib.types.attrsOf ( lib.types.submodule ( { config, ... }: { options = { name = lib.mkOption { type = lib.types.str; description = '' Certificate Authority Name. You can put what you want here, it will be displayed by the browser. ''; default = "Self Host Blocks Certificate"; }; paths = lib.mkOption { description = '' Paths where CA certs will be located. This option implements the SSL Generator contract. ''; type = shb.contracts.ssl.certs-paths; default = { key = "/var/lib/certs/cas/${config._module.args.name}.key"; cert = "/var/lib/certs/cas/${config._module.args.name}.cert"; }; }; systemdService = lib.mkOption { description = '' Systemd oneshot service used to generate the certs. This option implements the SSL Generator contract. ''; type = lib.types.str; default = "shb-certs-ca-${config._module.args.name}.service"; }; }; } ) ); }; certs.selfsigned = lib.mkOption { description = "Generate self-signed certificates signed by a Certificate Authority."; default = { }; type = lib.types.attrsOf ( lib.types.submodule ( { config, ... }: { options = { ca = lib.mkOption { type = lib.types.nullOr shb.contracts.ssl.cas; description = '' CA used to generate this certificate. Only used for self-signed. This contract input takes the contract output of the `shb.certs.cas` SSL block. ''; default = null; }; domain = lib.mkOption { type = lib.types.str; description = '' Domain to generate a certificate for. This can be a wildcard domain like `*.example.com`. ''; example = "example.com"; }; extraDomains = lib.mkOption { type = lib.types.listOf lib.types.str; description = '' Other domains to generate a certificate for. ''; default = [ ]; example = lib.literalExpression '' [ "sub1.example.com" "sub2.example.com" ] ''; }; group = lib.mkOption { type = lib.types.str; description = '' Unix group owning this certificate. ''; default = "root"; example = "nginx"; }; paths = lib.mkOption { description = '' Paths where certs will be located. This option implements the SSL Generator contract. ''; type = shb.contracts.ssl.certs-paths; default = { key = "/var/lib/certs/selfsigned/${config._module.args.name}.key"; cert = "/var/lib/certs/selfsigned/${config._module.args.name}.cert"; }; }; systemdService = lib.mkOption { description = '' Systemd oneshot service used to generate the certs. This option implements the SSL Generator contract. ''; type = lib.types.str; default = "shb-certs-cert-selfsigned-${config._module.args.name}.service"; }; reloadServices = lib.mkOption { description = '' The list of systemd services to call `systemctl try-reload-or-restart` on. ''; type = lib.types.listOf lib.types.str; default = [ ]; example = [ "nginx.service" ]; }; }; } ) ); }; certs.letsencrypt = lib.mkOption { description = "Generate certificates signed by [Let's Encrypt](https://letsencrypt.org/)."; default = { }; type = lib.types.attrsOf ( lib.types.submodule ( { config, ... }: { options = { domain = lib.mkOption { type = lib.types.str; description = '' Domain to generate a certificate for. This can be a wildcard domain like `*.example.com`. ''; example = "example.com"; }; extraDomains = lib.mkOption { type = lib.types.listOf lib.types.str; description = '' Other domains to generate a certificate for. ''; default = [ ]; example = lib.literalExpression '' [ "sub1.example.com" "sub2.example.com" ] ''; }; paths = lib.mkOption { description = '' Paths where certs will be located. This option implements the SSL Generator contract. ''; type = shb.contracts.ssl.certs-paths; default = { key = "/var/lib/acme/${config._module.args.name}/key.pem"; cert = "/var/lib/acme/${config._module.args.name}/cert.pem"; }; }; group = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' Unix group owning this certificate. ''; default = "acme"; example = "nginx"; }; systemdService = lib.mkOption { description = '' Systemd oneshot service used to generate the certs. This option implements the SSL Generator contract. ''; type = lib.types.str; default = "shb-certs-cert-letsencrypt-${config._module.args.name}.service"; }; afterAndWants = lib.mkOption { description = '' Systemd service(s) that must start successfully before attempting to reach acme. ''; type = lib.types.listOf lib.types.str; default = [ ]; example = lib.literalExpression '' [ "dnsmasq.service" ] ''; }; reloadServices = lib.mkOption { description = '' The list of systemd services to call `systemctl try-reload-or-restart` on. ''; type = lib.types.listOf lib.types.str; default = [ ]; example = [ "nginx.service" ]; }; dnsProvider = lib.mkOption { description = '' DNS provider to use. See https://go-acme.github.io/lego/dns/ for the list of supported providers. If null is given, use instead the reverse proxy to validate the domain. ''; type = lib.types.nullOr lib.types.str; default = null; example = "linode"; }; dnsResolver = lib.mkOption { description = "IP of a DNS server used to resolve hostnames."; type = lib.types.str; default = "8.8.8.8"; }; credentialsFile = lib.mkOption { type = lib.types.nullOr lib.types.path; description = '' Credentials file location for the chosen DNS provider. The content of this file must expose environment variables as written in the [documentation](https://go-acme.github.io/lego/dns/) of each DNS provider. For example, if the documentation says the credential must be located in the environment variable DNSPROVIDER_TOKEN, then the file content must be: DNSPROVIDER_TOKEN=xyz You can put non-secret environment variables here too or use shb.ssl.additionalcfg instead. ''; example = "/run/secrets/ssl"; default = null; }; additionalEnvironment = lib.mkOption { type = lib.types.attrsOf lib.types.str; default = { }; description = '' Additional environment variables used to configure the DNS provider. For secrets, use shb.ssl.credentialsFile instead. See the chosen provider's [documentation](https://go-acme.github.io/lego/dns/) for available options. ''; example = lib.literalExpression '' { DNSPROVIDER_TIMEOUT = "10"; DNSPROVIDER_PROPAGATION_TIMEOUT = "240"; } ''; }; makeAvailableToUser = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' Make all certificates available to given user. ''; default = null; }; adminEmail = lib.mkOption { description = "Admin email in case certificate retrieval goes wrong."; type = lib.types.str; }; stagingServer = lib.mkOption { description = "User Let's Encrypt's staging server."; type = lib.types.bool; default = false; }; debug = lib.mkOption { description = "Enable debug logging"; type = lib.types.bool; default = false; }; }; } ) ); }; }; config = let serviceName = lib.strings.removeSuffix ".service"; in lib.mkMerge [ # Generic assertions { assertions = lib.flatten ( lib.mapAttrsToList ( _name: certCfg: ( let domainInExtraDomains = (lib.lists.findFirstIndex (x: x == certCfg.domain) null certCfg.extraDomains) != null; firstDuplicateDomain = ( l: let sorted = lib.sort (x: y: x < y) l; maybeDupe = lib.lists.removePrefix (lib.lists.commonPrefix (lib.uniqueStrings sorted) sorted) sorted; in if maybeDupe == [ ] then null else lib.head maybeDupe ) certCfg.extraDomains; in [ { assertion = !domainInExtraDomains; message = "Error in SHB option for domain ${certCfg.domain}: do not repeat the domain name in the `extraDomain` option."; } { assertion = firstDuplicateDomain == null; message = "Error in SHB option for domain ${certCfg.domain}: `extraDomain` option cannot have duplicates, first offender is: ${firstDuplicateDomain} in the following list: ${lib.concatStringsSep " " certCfg.extraDomains}."; } ] ) ) (cfg.certs.selfsigned // cfg.certs.letsencrypt) ); } # Config for self-signed CA. { systemd.services = lib.mapAttrs' ( _name: caCfg: lib.nameValuePair (serviceName caCfg.systemdService) { wantedBy = [ "multi-user.target" ]; wants = [ config.shb.certs.systemdService ]; before = [ config.shb.certs.systemdService ]; serviceConfig.Type = "oneshot"; serviceConfig.RuntimeDirectory = serviceName caCfg.systemdService; # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix script = '' cd $RUNTIME_DIRECTORY cat >ca.template < /etc/ssl/certs/ca-bundle.crt cat /etc/static/ssl/certs/ca-bundle.crt > /etc/ssl/certs/ca-certificates.crt for file in ${ lib.concatStringsSep " " (mapAttrsToList (_name: caCfg: caCfg.paths.cert) cfg.cas.selfsigned) }; do cat "$file" >> /etc/ssl/certs/ca-bundle.crt cat "$file" >> /etc/ssl/certs/ca-certificates.crt done ''; } ); } # Config for self-signed cert. { systemd.services = lib.mapAttrs' ( _name: certCfg: lib.nameValuePair (serviceName certCfg.systemdService) { after = [ certCfg.ca.systemdService ]; requires = [ certCfg.ca.systemdService ]; wantedBy = [ "multi-user.target" ]; serviceConfig.RuntimeDirectory = serviceName certCfg.systemdService; # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix script = let extraDnsNames = lib.strings.concatStringsSep "\n" (map (n: "dns_name = ${n}") certCfg.extraDomains); chmod = cert: '' chown root:${certCfg.group} "${cert}" chmod 640 "${cert}" ''; in '' cd $RUNTIME_DIRECTORY # server cert template cat >server.template < /etc/tinyproxy/${name}.conf" ] ++ optionals (c.dynamicBindFile != "") [ "echo -n 'Bind ' >> /etc/tinyproxy/${name}.conf" "cat ${c.dynamicBindFile} >> /etc/tinyproxy/${name}.conf" ] ); }; }; in mkMerge (mapAttrsToList instanceConfig cfg); users.users.tinyproxy = { group = "tinyproxy"; isSystemUser = true; }; users.groups.tinyproxy = { }; }; meta.maintainers = with maintainers; [ tcheronneau ]; } ================================================ FILE: modules/blocks/vpn.nix ================================================ { config, pkgs, lib, ... }: let cfg = config.shb.vpn; quoteEach = lib.concatMapStrings (x: ''"${x}"''); nordvpnConfig = { name, dev, authFile, remoteServerIP, dependentServices ? [ ], }: '' client dev ${dev} proto tcp remote ${remoteServerIP} 443 resolv-retry infinite remote-random nobind tun-mtu 1500 tun-mtu-extra 32 mssfix 1450 persist-key persist-tun ping 15 ping-restart 0 ping-timer-rem reneg-sec 0 comp-lzo no status /tmp/openvpn/${name}.status remote-cert-tls server auth-user-pass ${authFile} verb 3 pull fast-io cipher AES-256-CBC auth SHA512 script-security 2 route-noexec route-up ${routeUp name dependentServices}/bin/routeUp.sh down ${routeDown name dependentServices}/bin/routeDown.sh -----BEGIN CERTIFICATE----- MIIFCjCCAvKgAwIBAgIBATANBgkqhkiG9w0BAQ0FADA5MQswCQYDVQQGEwJQQTEQ MA4GA1UEChMHTm9yZFZQTjEYMBYGA1UEAxMPTm9yZFZQTiBSb290IENBMB4XDTE2 MDEwMTAwMDAwMFoXDTM1MTIzMTIzNTk1OVowOTELMAkGA1UEBhMCUEExEDAOBgNV BAoTB05vcmRWUE4xGDAWBgNVBAMTD05vcmRWUE4gUm9vdCBDQTCCAiIwDQYJKoZI hvcNAQEBBQADggIPADCCAgoCggIBAMkr/BYhyo0F2upsIMXwC6QvkZps3NN2/eQF kfQIS1gql0aejsKsEnmY0Kaon8uZCTXPsRH1gQNgg5D2gixdd1mJUvV3dE3y9FJr XMoDkXdCGBodvKJyU6lcfEVF6/UxHcbBguZK9UtRHS9eJYm3rpL/5huQMCppX7kU eQ8dpCwd3iKITqwd1ZudDqsWaU0vqzC2H55IyaZ/5/TnCk31Q1UP6BksbbuRcwOV skEDsm6YoWDnn/IIzGOYnFJRzQH5jTz3j1QBvRIuQuBuvUkfhx1FEwhwZigrcxXu MP+QgM54kezgziJUaZcOM2zF3lvrwMvXDMfNeIoJABv9ljw969xQ8czQCU5lMVmA 37ltv5Ec9U5hZuwk/9QO1Z+d/r6Jx0mlurS8gnCAKJgwa3kyZw6e4FZ8mYL4vpRR hPdvRTWCMJkeB4yBHyhxUmTRgJHm6YR3D6hcFAc9cQcTEl/I60tMdz33G6m0O42s Qt/+AR3YCY/RusWVBJB/qNS94EtNtj8iaebCQW1jHAhvGmFILVR9lzD0EzWKHkvy WEjmUVRgCDd6Ne3eFRNS73gdv/C3l5boYySeu4exkEYVxVRn8DhCxs0MnkMHWFK6 MyzXCCn+JnWFDYPfDKHvpff/kLDobtPBf+Lbch5wQy9quY27xaj0XwLyjOltpiST LWae/Q4vAgMBAAGjHTAbMAwGA1UdEwQFMAMBAf8wCwYDVR0PBAQDAgEGMA0GCSqG SIb3DQEBDQUAA4ICAQC9fUL2sZPxIN2mD32VeNySTgZlCEdVmlq471o/bDMP4B8g nQesFRtXY2ZCjs50Jm73B2LViL9qlREmI6vE5IC8IsRBJSV4ce1WYxyXro5rmVg/ k6a10rlsbK/eg//GHoJxDdXDOokLUSnxt7gk3QKpX6eCdh67p0PuWm/7WUJQxH2S DxsT9vB/iZriTIEe/ILoOQF0Aqp7AgNCcLcLAmbxXQkXYCCSB35Vp06u+eTWjG0/ pyS5V14stGtw+fA0DJp5ZJV4eqJ5LqxMlYvEZ/qKTEdoCeaXv2QEmN6dVqjDoTAo k0t5u4YRXzEVCfXAC3ocplNdtCA72wjFJcSbfif4BSC8bDACTXtnPC7nD0VndZLp +RiNLeiENhk0oTC+UVdSc+n2nJOzkCK0vYu0Ads4JGIB7g8IB3z2t9ICmsWrgnhd NdcOe15BincrGA8avQ1cWXsfIKEjbrnEuEk9b5jel6NfHtPKoHc9mDpRdNPISeVa wDBM1mJChneHt59Nh8Gah74+TM1jBsw4fhJPvoc7Atcg740JErb904mZfkIEmojC VPhBHVQ9LHBAdM8qFI2kRK0IynOmAZhexlP/aT/kpEsEPyaZQlnBn3An1CRz8h0S PApL8PytggYKeQmRhl499+6jLxcZ2IegLfqq41dzIjwHwTMplg+1pKIOVojpWA== -----END CERTIFICATE----- key-direction 1 # # 2048 bit OpenVPN static key # -----BEGIN OpenVPN Static key V1----- e685bdaf659a25a200e2b9e39e51ff03 0fc72cf1ce07232bd8b2be5e6c670143 f51e937e670eee09d4f2ea5a6e4e6996 5db852c275351b86fc4ca892d78ae002 d6f70d029bd79c4d1c26cf14e9588033 cf639f8a74809f29f72b9d58f9b8f5fe fc7938eade40e9fed6cb92184abb2cc1 0eb1a296df243b251df0643d53724cdb 5a92a1d6cb817804c4a9319b57d53be5 80815bcfcb2df55018cc83fc43bc7ff8 2d51f9b88364776ee9d12fc85cc7ea5b 9741c4f598c485316db066d52db4540e 212e1518a9bd4828219e24b20d88f598 a196c9de96012090e333519ae18d3509 9427e7b372d348d352dc4c85e18cd4b9 3f8a56ddb2e64eb67adfc9b337157ff4 -----END OpenVPN Static key V1----- ''; routeUp = name: dependentServices: pkgs.writeShellApplication { name = "routeUp.sh"; runtimeInputs = [ pkgs.iproute2 pkgs.systemd pkgs.nettools ]; text = '' echo "Running route-up..." echo "dev=''${dev:?}" echo "ifconfig_local=''${ifconfig_local:?}" echo "route_vpn_gateway=''${route_vpn_gateway:?}" set -x ip rule ip rule add from "''${ifconfig_local:?}/32" table ${name} ip rule add to "''${route_vpn_gateway:?}/32" table ${name} ip rule ip route list table ${name} || : retVal=$? if [ $retVal -eq 2 ]; then echo "table is empty" elif [ $retVal -ne 0 ]; then exit 1 fi ip route add default via "''${route_vpn_gateway:?}" dev "''${dev:?}" table ${name} ip route flush cache ip route list table ${name} || : retVal=$? if [ $retVal -eq 2 ]; then echo "table is empty" elif [ $retVal -ne 0 ]; then exit 1 fi echo "''${ifconfig_local:?}" > /run/openvpn/${name}/ifconfig_local dependencies=(${quoteEach dependentServices}) for i in "''${dependencies[@]}"; do systemctl restart "$i" || : done echo "Running route-up DONE" ''; }; routeDown = name: dependentServices: pkgs.writeShellApplication { name = "routeDown.sh"; runtimeInputs = [ pkgs.iproute2 pkgs.systemd pkgs.nettools pkgs.coreutils ]; text = '' echo "Running route-down..." echo "dev=''${dev:?}" echo "ifconfig_local=''${ifconfig_local:?}" echo "route_vpn_gateway=''${route_vpn_gateway:?}" set -x ip rule ip rule del from "''${ifconfig_local:?}/32" table ${name} ip rule del to "''${route_vpn_gateway:?}/32" table ${name} ip rule # This will probably fail because the dev is already gone. ip route list table ${name} || : retVal=$? if [ $retVal -eq 2 ]; then echo "table is empty" elif [ $retVal -ne 0 ]; then exit 1 fi ip route del default via "''${route_vpn_gateway:?}" dev "''${dev:?}" table ${name} || : ip route flush cache ip route list table ${name} || : retVal=$? if [ $retVal -eq 2 ]; then echo "table is empty" elif [ $retVal -ne 0 ]; then exit 1 fi rm /run/openvpn/${name}/ifconfig_local dependencies=(${quoteEach dependentServices}) for i in "''${dependencies[@]}"; do systemctl stop "$i" || : done echo "Running route-down DONE" ''; }; in { options = let instanceOption = lib.types.submodule { options = { enable = lib.mkEnableOption "OpenVPN config"; package = lib.mkPackageOption pkgs "openvpn" { }; provider = lib.mkOption { description = "VPN provider, if given uses ready-made configuration."; type = lib.types.nullOr (lib.types.enum [ "nordvpn" ]); default = null; }; dev = lib.mkOption { description = "Name of the interface."; type = lib.types.str; example = "tun0"; }; routingNumber = lib.mkOption { description = "Unique number used to route packets."; type = lib.types.int; example = 10; }; remoteServerIP = lib.mkOption { description = "IP of the VPN server to connect to."; type = lib.types.str; }; authFile = lib.mkOption { description = "Location of file holding authentication secrets for provider."; type = lib.types.anything; }; proxyPort = lib.mkOption { description = "If not null, sets up a proxy that listens on the given port and sends traffic to the VPN."; type = lib.types.nullOr lib.types.int; default = null; }; }; }; in { shb.vpn = lib.mkOption { description = "OpenVPN instances."; default = { }; type = lib.types.attrsOf instanceOption; }; }; config = { services.openvpn.servers = let instanceConfig = name: c: lib.mkIf c.enable { ${name} = { autoStart = true; up = "mkdir -p /run/openvpn/${name}"; config = nordvpnConfig { inherit name; inherit (c) dev remoteServerIP authFile; dependentServices = lib.optional (c.proxyPort != null) "tinyproxy-${name}.service"; }; }; }; in lib.mkMerge (lib.mapAttrsToList instanceConfig cfg); systemd.tmpfiles.rules = map (name: "d /tmp/openvpn/${name}.status 0700 root root") ( lib.attrNames cfg ); networking.iproute2.enable = true; networking.iproute2.rttablesExtraConfig = lib.concatStringsSep "\n" ( lib.mapAttrsToList (name: c: "${toString c.routingNumber} ${name}") cfg ); shb.tinyproxy = let instanceConfig = name: c: lib.mkIf (c.enable && c.proxyPort != null) { ${name} = { enable = true; # package = pkgs.tinyproxy.overrideAttrs (old: { # withDebug = false; # patches = old.patches ++ [ # (pkgs.fetchpatch { # name = ""; # url = "https://github.com/tinyproxy/tinyproxy/pull/494/commits/2532ba09896352b31f3538d7819daa1fc3f829f1.patch"; # sha256 = "sha256-Q0MkHnttW8tH3+hoCt9ACjHjmmZQgF6pC/menIrU0Co="; # }) # ]; # }); dynamicBindFile = "/run/openvpn/${name}/ifconfig_local"; settings = { Port = c.proxyPort; Listen = "127.0.0.1"; Syslog = "On"; LogLevel = "Info"; Allow = [ "127.0.0.1" "::1" ]; ViaProxyName = ''"tinyproxy"''; }; }; }; in lib.mkMerge (lib.mapAttrsToList instanceConfig cfg); }; } ================================================ FILE: modules/blocks/zfs.nix ================================================ { config, pkgs, lib, ... }: let cfg = config.shb.zfs; in { options.shb.zfs = { defaultPoolName = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "ZFS pool name datasets should be created on if no pool name is given in the dataset."; }; datasets = lib.mkOption { description = '' ZFS Datasets. Each entry in the attrset will be created and mounted in the given path. The attrset name is the dataset name. This block implements the following contracts: - mount ''; default = { }; example = lib.literalExpression '' shb.zfs."safe/postgresql".path = "/var/lib/postgresql"; ''; type = lib.types.attrsOf ( lib.types.submodule { options = { enable = lib.mkEnableOption "shb.zfs.datasets"; poolName = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "ZFS pool name this dataset should be created on. Overrides the defaultPoolName."; }; path = lib.mkOption { type = lib.types.str; description = "Path this dataset should be mounted on. If the string 'none' is given, the dataset will not be mounted."; }; mode = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "If non null, unix mode to apply to the dataset root folder."; default = null; example = "ug=rwx,g+s"; }; owner = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "If non null, unix user to apply to the dataset root folder."; default = null; example = "syncthing"; }; group = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "If non null, unix group to apply to the dataset root folder."; default = null; example = "syncthing"; }; defaultACLs = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' If non null, default ACL to set on the dataset root folder. Executes "setfacl -d -m $acl $path" ''; default = null; example = "g:syncthing:rwX"; }; }; } ); }; }; config = { assertions = [ { assertion = lib.any (x: x.poolName == null) (lib.mapAttrsToList (n: v: v) cfg.datasets) -> cfg.defaultPoolName != null; message = "Cannot have both datasets.poolName and defaultPoolName set to null"; } ]; system.activationScripts = lib.mapAttrs' ( name: cfg': let dataset = (if cfg'.poolName != null then cfg'.poolName else cfg.defaultPoolName) + "/" + name; in lib.attrsets.nameValuePair "zfsCreate-${name}" { text = '' ${pkgs.zfs}/bin/zfs list ${dataset} > /dev/null 2>&1 \ || ${pkgs.zfs}/bin/zfs create \ -o mountpoint=none \ ${dataset} || : [ "$(${pkgs.zfs}/bin/zfs get -H mountpoint -o value ${dataset})" = ${cfg'.path} ] \ || ${pkgs.zfs}/bin/zfs set \ mountpoint="${cfg'.path}" \ ${dataset} '' + lib.optionalString (cfg'.path != "none" && cfg'.mode != null) '' chmod "${cfg'.mode}" "${cfg'.path}" '' + lib.optionalString (cfg'.path != "none" && cfg'.owner != null) '' chown "${cfg'.owner}" "${cfg'.path}" '' + lib.optionalString (cfg'.path != "none" && cfg'.group != null) '' chown :"${cfg'.group}" "${cfg'.path}" '' + lib.optionalString (cfg'.path != "none" && cfg'.defaultACLs != null) '' ${pkgs.acl}/bin/setfacl -d -m "${cfg'.defaultACLs}" "${cfg'.path}" ''; } ) cfg.datasets; }; } ================================================ FILE: modules/contracts/backup/docs/default.md ================================================ # Backup Contract {#contract-backup} This NixOS contract represents a backup job that will backup one or more files or directories on a regular schedule. It is a contract between a service that has files to be backed up and a service that backs up files. ## Contract Reference {#contract-backup-options} These are all the options that are expected to exist for this contract to be respected. ```{=include=} options id-prefix: contracts-backup-options- list-id: selfhostblocks-options source: @OPTIONS_JSON@ ``` ## Usage {#contract-backup-usage} A service that can be backed up will provide a `backup` option. Such a service is a `requester` providing a `request` for a module `provider` of this contract. What this option defines is, from the user perspective - that is _you_ - an implementation detail but it will at least define what directories to backup, the user to backup with and possibly hooks to run before or after the backup job runs. Here is an example module defining such a `backup` option: ```nix { options = { myservice.backup = mkOption { type = lib.types.submodule { options = contracts.backup.request; }; default = { user = "myservice"; sourceDirectories = [ "/var/lib/myservice" ]; }; }; }; }; ``` Now, on the other side we have a service that uses this `backup` option and actually backs up files. This service is a `provider` of this contract and will provide a `result` option. Let's assume such a module is available under the `backupservice` option and that one can create multiple backup instances under `backupservice.instances`. Then, to actually backup the `myservice` service, one would write: ```nix backupservice.instances.myservice = { request = myservice.backup; settings = { enable = true; repository = { path = "/srv/backup/myservice"; }; # ... Other options specific to backupservice like scheduling. }; }; ``` It is advised to backup files to different location, to improve redundancy. Thanks to using contracts, this can be made easily either with the same `backupservice`: ```nix backupservice.instances.myservice_2 = { request = myservice.backup; settings = { enable = true; repository = { path = ""; }; }; }; ``` Or with another module `backupservice_2`! ## Providers of the Backup Contract {#contract-backup-providers} - [Restic block](blocks-restic.html). - [Borgbackup block](blocks-borgbackup.html) [WIP]. ## Requester Blocks and Services {#contract-backup-requesters} - Audiobookshelf (no manual yet) - Deluge (no manual yet) - Grocy (no manual yet) - Hledger (no manual yet) - Home Assistant (no manual yet) - Jellyfin (no manual yet) - LLDAP (no manual yet) - [Nextcloud](services-nextcloud.html#services-nextcloudserver-usage-backup). - [Vaultwarden](services-vaultwarden.html#services-vaultwarden-backup). - *arr (no manual yet) ================================================ FILE: modules/contracts/backup/dummyModule.nix ================================================ { lib, shb, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule; in { imports = [ ../../../lib/module.nix ]; options.shb.contracts.backup = mkOption { description = '' Contract for backing up files between a requester module and a provider module. The requester communicates to the provider what files to backup through the `request` options. The provider reads from the `request` options and backs up the requested files. It communicates to the requester what script is used to backup and restore the files through the `result` options. ''; type = submodule { options = shb.contracts.backup.contract; }; }; } ================================================ FILE: modules/contracts/backup/test.nix ================================================ { pkgs, lib, shb, }: let inherit (lib) concatMapStringsSep getAttrFromPath mkIf optionalAttrs setAttrByPath ; in { name, providerRoot, modules ? [ ], username ? "me", sourceDirectories ? [ "/opt/files/A" "/opt/files/B" ], settings, # { repository, config } -> attrset extraConfig ? null, # { username, config } -> attrset }: shb.test.runNixOSTest { inherit name; nodes.machine = { config, ... }: { imports = [ shb.test.baseImports ] ++ modules; config = lib.mkMerge [ (setAttrByPath providerRoot { request = { inherit sourceDirectories; user = username; }; settings = settings { inherit config; repository = "/opt/repos/${name}"; }; }) (mkIf (username != "root") { users.users.${username} = { isSystemUser = true; extraGroups = [ "sudoers" ]; group = "root"; }; }) (optionalAttrs (extraConfig != null) (extraConfig { inherit username config; })) ]; }; extraPythonPackages = p: [ p.dictdiffer ]; skipTypeCheck = true; testScript = { nodes, ... }: let provider = (getAttrFromPath providerRoot nodes.machine).result; in '' from dictdiffer import diff username = "${username}" sourceDirectories = [ ${concatMapStringsSep ", " (x: ''"${x}"'') sourceDirectories} ] def list_files(dir): files_and_content = {} files = machine.succeed(f"""find {dir} -type f""").split("\n")[:-1] for f in files: content = machine.succeed(f"""cat {f}""").strip() files_and_content[f] = content return files_and_content def assert_files(dir, files): result = list(diff(list_files(dir), files)) if len(result) > 0: raise Exception("Unexpected files:", result) with subtest("Create initial content"): for path in sourceDirectories: machine.succeed(f""" mkdir -p {path} echo repo_fileA_1 > {path}/fileA echo repo_fileB_1 > {path}/fileB chown {username}: -R {path} chmod go-rwx -R {path} """) for path in sourceDirectories: assert_files(path, { f'{path}/fileA': 'repo_fileA_1', f'{path}/fileB': 'repo_fileB_1', }) with subtest("First backup in repo"): print(machine.succeed("systemctl cat ${provider.backupService}")) machine.succeed("systemctl start ${provider.backupService}") with subtest("New content"): for path in sourceDirectories: machine.succeed(f""" echo repo_fileA_2 > {path}/fileA echo repo_fileB_2 > {path}/fileB """) assert_files(path, { f'{path}/fileA': 'repo_fileA_2', f'{path}/fileB': 'repo_fileB_2', }) with subtest("Delete content"): for path in sourceDirectories: machine.succeed(f"""rm -r {path}/*""") assert_files(path, {}) with subtest("Restore initial content from repo"): machine.succeed("""${provider.restoreScript} restore latest""") for path in sourceDirectories: assert_files(path, { f'{path}/fileA': 'repo_fileA_1', f'{path}/fileB': 'repo_fileB_1', }) ''; } ================================================ FILE: modules/contracts/backup.nix ================================================ { lib, shb, ... }: let inherit (lib) concatStringsSep literalMD mkOption optionalAttrs optionalString ; inherit (lib.types) listOf nonEmptyListOf submodule str ; inherit (shb) anyNotNull; in { mkRequest = { user ? "", userText ? null, sourceDirectories ? [ "/var/lib/example" ], sourceDirectoriesText ? null, excludePatterns ? [ ], excludePatternsText ? null, beforeBackup ? [ ], beforeBackupText ? null, afterBackup ? [ ], afterBackupText ? null, }: mkOption { description = '' Request part of the backup contract. Options set by the requester module enforcing how to backup files. ''; default = { inherit user sourceDirectories excludePatterns; hooks = { inherit beforeBackup afterBackup; }; }; defaultText = optionalString (anyNotNull [ userText sourceDirectoriesText excludePatternsText beforeBackupText afterBackupText ]) (literalMD '' { user = ${if userText != null then userText else user}; sourceDirectories = ${ if sourceDirectoriesText != null then sourceDirectoriesText else "[ " + concatStringsSep " " sourceDirectories + " ]" }; excludePatterns = ${ if excludePatternsText != null then excludePatternsText else "[ " + concatStringsSep " " excludePatterns + " ]" }; hooks.beforeBackup = ${ if beforeBackupText != null then beforeBackupText else "[ " + concatStringsSep " " beforeBackup + " ]" }; hooks.afterBackup = ${ if afterBackupText != null then afterBackupText else "[ " + concatStringsSep " " afterBackup + " ]" }; }; ''); type = submodule { options = { user = mkOption { description = '' Unix user doing the backups. ''; type = str; example = "vaultwarden"; default = user; } // optionalAttrs (userText != null) { defaultText = literalMD userText; }; sourceDirectories = mkOption { description = "Directories to backup."; type = nonEmptyListOf str; example = "/var/lib/vaultwarden"; default = sourceDirectories; } // optionalAttrs (sourceDirectoriesText != null) { defaultText = literalMD sourceDirectoriesText; }; excludePatterns = mkOption { description = "File patterns to exclude."; type = listOf str; default = excludePatterns; } // optionalAttrs (excludePatternsText != null) { defaultText = literalMD excludePatternsText; }; hooks = mkOption { description = "Hooks to run around the backup."; default = { }; type = submodule { options = { beforeBackup = mkOption { description = "Hooks to run before backup."; type = listOf str; default = beforeBackup; } // optionalAttrs (beforeBackupText != null) { defaultText = literalMD beforeBackupText; }; afterBackup = mkOption { description = "Hooks to run after backup."; type = listOf str; default = afterBackup; } // optionalAttrs (afterBackupText != null) { defaultText = literalMD afterBackupText; }; }; }; }; }; }; }; mkResult = { restoreScript ? "restore", restoreScriptText ? null, backupService ? "backup.service", backupServiceText ? null, }: mkOption { description = '' Result part of the backup contract. Options set by the provider module that indicates the name of the backup and restore scripts. ''; default = { inherit restoreScript backupService; }; defaultText = optionalString (anyNotNull [ restoreScriptText backupServiceText ]) (literalMD '' { restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript}; backupService = ${if backupServiceText != null then backupServiceText else backupService}; } ''); type = submodule { options = { restoreScript = mkOption { description = '' Name of script that can restore the database. One can then list snapshots with: ```bash $ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots ``` And restore the database with: ```bash $ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest ``` ''; type = str; default = restoreScript; } // optionalAttrs (restoreScriptText != null) { defaultText = literalMD restoreScriptText; }; backupService = mkOption { description = '' Name of service backing up the database. This script can be ran manually to backup the database: ```bash $ systemctl start ${if backupServiceText != null then backupServiceText else backupService} ``` ''; type = str; default = backupService; } // optionalAttrs (backupServiceText != null) { defaultText = literalMD backupServiceText; }; }; }; }; } ================================================ FILE: modules/contracts/dashboard/docs/default.md ================================================ # Dashboard Contract {#contract-dashboard} This NixOS contract is used for user-facing services that want to be displayed on a dashboard. It is a contract between a service that can be accessed through an URL and a service that wants to show a list of those services. ## Providers {#contract-dashboard-providers} The providers of this contract in SHB are: - The homepage service under its [shb.homepage.servicesGroups..services..dashboard](#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.dashboard) option. ## Usage {#contracts-dashboard-usage} A service that can be shown on a dashboard will provide a `dashboard` option. Here is an example module defining such a requester option for this dashboard contract: ```nix { options = { myservice.dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${config.myservice.subdomain}.${config.myservice.domain}"; internalUrl = "http://127.0.0.1:${config.myservice.port}"; }; }; }; }; }; ``` Then, plug both consumer and provider together in the `config`: ```nix { config = { = { dashboard.request = config.myservice.dashboard.request; }; }; } ``` And that's it for the contract part. For more specific details on each provider, go to their respective manual pages. ## Contract Reference {#contract-dashboard-options} These are all the options that are expected to exist for this contract to be respected. ```{=include=} options id-prefix: contracts-dashboard-options- list-id: selfhostblocks-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/contracts/dashboard/dummyModule.nix ================================================ { lib, shb, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule; in { imports = [ ../../../lib/module.nix ]; options.shb.contracts.dashboard = mkOption { description = '' Contract for user-facing services that want to be displayed on a dashboard. The requester communicates to the provider how to access the service through the `request` options. The provider reads from the `request` options and configures what is necessary on its side to show the service and check its availability. It does not communicate back to the requester. ''; type = submodule { options = shb.contracts.dashboard.contract; }; }; } ================================================ FILE: modules/contracts/dashboard.nix ================================================ { lib, ... }: let inherit (lib) mkOption; inherit (lib.types) nullOr submodule str ; in { mkRequest = { serviceName ? "", externalUrl ? "", externalUrlText ? null, internalUrl ? null, internalUrlText ? null, apiKey ? null, }: mkOption { description = '' Request part of the dashboard contract. ''; default = { }; type = submodule { options = { externalUrl = mkOption { description = '' URL at which the service can be accessed. This URL should go through the reverse proxy. ''; type = str; default = externalUrl; example = "https://jellyfin.example.com"; } // (lib.optionalAttrs (externalUrlText != null) { defaultText = externalUrlText; }); internalUrl = mkOption { description = '' URL at which the service can be accessed directly. This URL should bypass the reverse proxy. It can be used for example to ping the service and making sure it is up and running correctly. ''; type = nullOr str; default = internalUrl; example = "http://127.0.0.1:8081"; } // (lib.optionalAttrs (internalUrlText != null) { defaultText = internalUrlText; }); }; }; }; mkResult = { }: mkOption { description = '' Result part of the dashboard contract. No option is provided here. ''; default = { }; type = submodule { options = { }; }; }; } ================================================ FILE: modules/contracts/databasebackup/docs/default.md ================================================ # Database Backup Contract {#contract-databasebackup} This NixOS contract represents a backup job that will backup everything in one database on a regular schedule. It is a contract between a service that has database dumps to be backed up and a service that backs up databases dumps. ## Contract Reference {#contract-databasebackup-options} These are all the options that are expected to exist for this contract to be respected. ```{=include=} options id-prefix: contracts-databasebackup-options- list-id: selfhostblocks-options source: @OPTIONS_JSON@ ``` ## Usage {#contract-databasebackup-usage} A database that can be backed up will provide a `databasebackup` option. Such a service is a `requester` providing a `request` for a module `provider` of this contract. What this option defines is, from the user perspective - that is _you_ - an implementation detail but it will at least define how to create a database dump, the user to backup with and how to restore from a database dump. Here is an example module defining such a `databasebackup` option: ```nix { options = { myservice.databasebackup = mkOption { type = contracts.databasebackup.request; default = { user = "myservice"; backupCmd = '' ${pkgs.postgresql}/bin/pg_dumpall | ${pkgs.gzip}/bin/gzip --rsyncable ''; restoreCmd = '' ${pkgs.gzip}/bin/gunzip | ${pkgs.postgresql}/bin/psql postgres ''; }; }; }; }; ``` Now, on the other side we have a service that uses this `backup` option and actually backs up files. This service is a `provider` of this contract and will provide a `result` option. Let's assume such a module is available under the `databasebackupservice` option and that one can create multiple backup instances under `databasebackupservice.instances`. Then, to actually backup the `myservice` service, one would write: ```nix databasebackupservice.instances.myservice = { request = myservice.databasebackup; settings = { enable = true; repository = { path = "/srv/backup/myservice"; }; # ... Other options specific to backupservice like scheduling. }; }; ``` It is advised to backup files to different location, to improve redundancy. Thanks to using contracts, this can be made easily either with the same `databasebackupservice`: ```nix databasebackupservice.instances.myservice_2 = { request = myservice.backup; settings = { enable = true; repository = { path = ""; }; }; }; ``` Or with another module `databasebackupservice_2`! ## Providers of the Database Backup Contract {#contract-databasebackup-providers} - [Restic block](blocks-restic.html). - [Borgbackup block](blocks-borgbackup.html) [WIP]. ## Requester Blocks and Services {#contract-databasebackup-requesters} - [PostgreSQL](blocks-postgresql.html#blocks-postgresql-contract-databasebackup). ================================================ FILE: modules/contracts/databasebackup/dummyModule.nix ================================================ { lib, shb, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule; in { imports = [ ../../../lib/module.nix ]; options.shb.contracts.databasebackup = mkOption { description = '' Contract for database backup between a requester module and a provider module. The requester communicates to the provider how to backup the database through the `request` options. The provider reads from the `request` options and backs up the database as requested. It communicates to the requester what script is used to backup and restore the database through the `result` options. ''; type = submodule { options = shb.contracts.databasebackup.contract; }; }; } ================================================ FILE: modules/contracts/databasebackup/test.nix ================================================ { pkgs, lib, shb, }: let inherit (lib) getAttrFromPath mkIf optionalAttrs setAttrByPath ; in { name, requesterRoot, providerRoot, extraConfig ? null, # { config, database } -> attrset modules ? [ ], database ? "me", settings, # { repository, config } -> attrset }: shb.test.runNixOSTest { inherit name; nodes.machine = { config, ... }: { imports = [ shb.test.baseImports ] ++ modules; config = lib.mkMerge [ (setAttrByPath providerRoot { request = (getAttrFromPath requesterRoot config).request; settings = settings { inherit config; repository = "/opt/repos/database"; }; }) (mkIf (database != "root") { users.users.${database} = { isSystemUser = true; extraGroups = [ "sudoers" ]; group = "root"; }; }) (optionalAttrs (extraConfig != null) (extraConfig { inherit config database; })) ]; }; testScript = { nodes, ... }: let provider = getAttrFromPath providerRoot nodes.machine; in '' import csv start_all() machine.wait_for_unit("postgresql.service") machine.wait_for_open_port(5432) def peer_cmd(cmd, db="me"): return "sudo -u ${database} psql -U ${database} {db} --csv --command \"{cmd}\"".format(cmd=cmd, db=db) def query(query): res = machine.succeed(peer_cmd(query)) return list(dict(l) for l in csv.DictReader(res.splitlines())) def cmp_tables(a, b): for i in range(max(len(a), len(b))): diff = set(a[i]) ^ set(b[i]) if len(diff) > 0: raise Exception(i, diff) table = [{'name': 'car', 'count': '1'}, {'name': 'lollipop', 'count': '2'}] with subtest("create fixture"): machine.succeed(peer_cmd("CREATE TABLE test (name text, count int)")) machine.succeed(peer_cmd("INSERT INTO test VALUES ('car', 1), ('lollipop', 2)")) res = query("SELECT * FROM test") cmp_tables(res, table) with subtest("backup"): print(machine.succeed("systemctl cat ${provider.result.backupService}")) print(machine.succeed("ls -l /run/hardcodedsecrets/hardcodedsecret_passphrase")) machine.succeed("systemctl start ${provider.result.backupService}") with subtest("drop database"): machine.succeed(peer_cmd("DROP DATABASE ${database}", db="postgres")) machine.fail(peer_cmd("SELECT * FROM test")) with subtest("restore"): print(machine.succeed("readlink -f $(type ${provider.result.restoreScript})")) machine.succeed("${provider.result.restoreScript} restore latest ") with subtest("check restoration"): res = query("SELECT * FROM test") cmp_tables(res, table) ''; } ================================================ FILE: modules/contracts/databasebackup.nix ================================================ { lib, shb, ... }: let inherit (lib) mkOption literalExpression literalMD optionalAttrs optionalString ; inherit (lib.types) submodule str; inherit (shb) anyNotNull; in { mkRequest = { user ? "root", userText ? null, backupName ? "dump", backupNameText ? null, backupCmd ? "", backupCmdText ? null, restoreCmd ? "", restoreCmdText ? null, }: mkOption { description = '' Request part of the database backup contract. Options set by the requester module enforcing how to backup files. ''; default = { inherit user backupName backupCmd restoreCmd ; }; defaultText = optionalString (anyNotNull [ userText backupNameText backupCmdText restoreCmdText ]) (literalMD '' { user = ${if userText != null then userText else user}; backupName = ${if backupNameText != null then backupNameText else backupName}; backupCmd = ${if backupCmdText != null then backupCmdText else backupCmd}; restoreCmd = ${if restoreCmdText != null then restoreCmdText else restoreCmd}; } ''); type = submodule { options = { user = mkOption { description = '' Unix user doing the backups. This should be an admin user having access to all databases. ''; type = str; example = "postgres"; default = user; } // optionalAttrs (userText != null) { defaultText = literalMD userText; }; backupName = mkOption { description = "Name of the backup in the repository."; type = str; example = "postgresql.sql"; default = backupName; } // optionalAttrs (backupNameText != null) { defaultText = literalMD backupNameText; }; backupCmd = mkOption { description = "Command that produces the database dump on stdout."; type = str; example = literalExpression '' ''${pkgs.postgresql}/bin/pg_dumpall | ''${pkgs.gzip}/bin/gzip --rsyncable ''; default = backupCmd; } // optionalAttrs (backupCmdText != null) { defaultText = literalMD backupCmdText; }; restoreCmd = mkOption { description = "Command that reads the database dump on stdin and restores the database."; type = str; example = literalExpression '' ''${pkgs.gzip}/bin/gunzip | ''${pkgs.postgresql}/bin/psql postgres ''; default = restoreCmd; } // optionalAttrs (restoreCmdText != null) { defaultText = literalMD restoreCmdText; }; }; }; }; mkResult = { restoreScript ? "restore", restoreScriptText ? null, backupService ? "backup.service", backupServiceText ? null, }: mkOption { description = '' Result part of the database backup contract. Options set by the provider module that indicates the name of the backup and restore scripts. ''; default = { inherit restoreScript backupService; }; defaultText = optionalString (anyNotNull [ restoreScriptText backupServiceText ]) (literalMD '' { restoreScript = ${if restoreScriptText != null then restoreScriptText else restoreScript}; backupService = ${if backupServiceText != null then backupServiceText else backupService}; } ''); type = submodule { options = { restoreScript = mkOption { description = '' Name of script that can restore the database. One can then list snapshots with: ```bash $ ${if restoreScriptText != null then restoreScriptText else restoreScript} snapshots ``` And restore the database with: ```bash $ ${if restoreScriptText != null then restoreScriptText else restoreScript} restore latest ``` ''; type = str; default = restoreScript; } // optionalAttrs (restoreScriptText != null) { defaultText = literalMD restoreScriptText; }; backupService = mkOption { description = '' Name of service backing up the database. This script can be ran manually to backup the database: ```bash $ systemctl start ${if backupServiceText != null then backupServiceText else backupService} ``` ''; type = str; default = backupService; } // optionalAttrs (backupServiceText != null) { defaultText = literalMD backupServiceText; }; }; }; }; } ================================================ FILE: modules/contracts/default.nix ================================================ { pkgs, lib, shb, }: let inherit (lib) mkOption optionalAttrs; inherit (lib.types) anything; mkContractFunctions = { mkRequest, mkResult, }: { mkRequester = requestCfg: { request = mkRequest requestCfg; result = mkResult { }; }; mkProvider = { resultCfg, settings ? { }, }: { request = mkRequest { }; result = mkResult resultCfg; } // optionalAttrs (settings != { }) { inherit settings; }; contract = { request = mkRequest { }; result = mkResult { }; settings = mkOption { description = '' Optional attribute set with options specific to the provider. ''; type = anything; }; }; }; importContract = module: let importedModule = pkgs.callPackage module { shb = shb // { inherit contracts; }; }; in mkContractFunctions { inherit (importedModule) mkRequest mkResult; }; contracts = { databasebackup = importContract ./databasebackup.nix; dashboard = importContract ./dashboard.nix; backup = importContract ./backup.nix; mount = pkgs.callPackage ./mount.nix { }; secret = importContract ./secret.nix; ssl = pkgs.callPackage ./ssl.nix { }; test = { secret = pkgs.callPackage ./secret/test.nix { inherit shb; }; databasebackup = pkgs.callPackage ./databasebackup/test.nix { inherit shb; }; backup = pkgs.callPackage ./backup/test.nix { inherit shb; }; }; }; in contracts ================================================ FILE: modules/contracts/mount.nix ================================================ { lib, ... }: lib.types.submodule { freeformType = lib.types.anything; options = { path = lib.mkOption { type = lib.types.str; description = "Path to be mounted."; }; }; } ================================================ FILE: modules/contracts/secret/docs/default.md ================================================ # Secret Contract {#contract-secret} This NixOS contract represents a secret file that must be created out of band - from outside the nix store - and that must be placed in an expected location with expected permission. More formally, this contract is made between a requester module - the one needing a secret - and a provider module - the one creating the secret and making it available. ## Motivation {#contract-secret-motivation} Let's provide the [ldap SHB module][ldap-module] option `ldapUserPasswordFile` with a secret managed by [sops-nix][]. [ldap-module]: TODO [sops-nix]: TODO Without the secret contract, configuring the option would look like so: ```nix sops.secrets."ldap/user_password" = { mode = "0440"; owner = "lldap"; group = "lldap"; restartUnits = [ "lldap.service" ]; sopsFile = ./secrets.yaml; }; shb.lldap.userPassword.result = config.sops.secrets."ldap/user_password".result; ``` The problem this contract intends to fix is how to ensure the end user knows what values to give to the `mode`, `owner`, `group` and `restartUnits` options? If lucky, the documentation of the option would tell them or more likely, they will need to figure it out by looking at the module source code. Not a great user experience. Now, with this contract, a layer on top of `sops` is added which is found under `shb.sops`. The configuration then becomes: ```nix shb.sops.secret."ldap/user_password" = { request = config.shb.lldap.userPassword.request; settings.sopsFile = ./secrets.yaml; }; shb.lldap.userPassword.result = config.shb.sops.secret."ldap/user_password".result; ``` The issue is now gone as the responsibility falls on the module maintainer for describing how the secret should be provided. If taking advantage of the `sops.defaultSopsFile` option like so: ```nix sops.defaultSopsFile = ./secrets.yaml; ``` Then the snippet above is even more simplified: ```nix shb.sops.secret."ldap/user_password".request = config.shb.lldap.userPassword.request; shb.lldap.userPassword.result = config.shb.sops.secret."ldap/user_password".result; ``` ## Contract Reference {#contract-secret-options} These are all the options that are expected to exist for this contract to be respected. ```{=include=} options id-prefix: contracts-secret-options- list-id: selfhostblocks-options source: @OPTIONS_JSON@ ``` ## Usage {#contract-secret-usage} A contract involves 3 parties: - The implementer of a requester module. - The implementer of a provider module. - The end user which sets up the requester module and picks a provider implementation. The usage of this contract is similarly separated into 3 sections. ### Requester Module {#contract-secret-usage-requester} Here is an example module requesting two secrets through the `secret` contract. ```nix { config, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule; in { options = { myservice = mkOption { type = submodule { options = { adminPassword = contracts.secret.mkRequester { owner = "myservice"; group = "myservice"; mode = "0440"; restartUnits = [ "myservice.service" ]; }; databasePassword = contracts.secret.mkRequester { owner = "myservice"; # group defaults to "root" # mode defaults to "0400" restartUnits = [ "myservice.service" "mysql.service" ]; }; }; }; }; }; config = { // Do something with the secrets, available at: // config.myservice.adminPassword.result.path // config.myservice.databasePassword.result.path }; }; ``` ### Provider Module {#contract-secret-usage-provider} Now, on the other side, we have a module that uses those options and provides a secret. Let's assume such a module is available under the `secretservice` option and that one can create multiple instances. ```nix { config, ... }: let inherit (lib) mkOption; inherit (lib.types) attrsOf submodule; contracts = pkgs.callPackage ./contracts {}; in { options.secretservice.secret = mkOption { description = "Secret following the secret contract."; default = {}; type = attrsOf (submodule ({ name, options, ... }: { options = contracts.secret.mkProvider { settings = mkOption { description = '' Settings specific to the secrets provider. ''; type = submodule { options = { secretFile = lib.mkOption { description = "File containing the encrypted secret."; type = lib.types.path; }; }; }; }; resultCfg = { path = "/run/secrets/${name}"; pathText = "/run/secrets/"; }; }; })); }; config = { // ... }; } ``` ### End User {#contract-secret-usage-enduser} The end user's responsibility is now to do some plumbing. They will setup the provider module - here `secretservice` - with the options set by the requester module, while also setting other necessary options to satisfy the provider service. And then they will give back the result to the requester module `myservice`. ```nix secretservice.secret."adminPassword" = { request = myservice.adminPasswor".request; settings.secretFile = ./secret.yaml; }; myservice.adminPassword.result = secretservice.secret."adminPassword".result; secretservice.secret."databasePassword" = { request = myservice.databasePassword.request; settings.secretFile = ./secret.yaml; }; myservice.databasePassword.result = secretservice.service."databasePassword".result; ``` Assuming the `secretservice` module accepts default options, the above snippet could be reduced to: ```nix secretservice.default.secretFile = ./secret.yaml; secretservice.secret."adminPassword".request = myservice.adminPasswor".request; myservice.adminPassword.result = secretservice.secret."adminPassword".result; secretservice.secret."databasePassword".request = myservice.databasePassword.request; myservice.databasePassword.result = secretservice.service."databasePassword".result; ``` The plumbing of request from the requester to the provider and then the result from the provider back to the requester is quite explicit in this snippet. ================================================ FILE: modules/contracts/secret/dummyModule.nix ================================================ { lib, shb, ... }: let inherit (lib) mkOption; inherit (lib.types) submodule; in { imports = [ ../../../lib/module.nix ]; options.shb.contracts.secret = mkOption { description = '' Contract for secrets between a requester module and a provider module. The requester communicates to the provider some properties the secret should have through the `request.*` options. The provider reads from the `request.*` options and creates the secret as requested. It then communicates to the requester where the secret can be found through the `result.*` options. ''; type = submodule { options = shb.contracts.secret.contract; }; }; } ================================================ FILE: modules/contracts/secret/test.nix ================================================ { pkgs, lib, shb, }: let inherit (lib) getAttrFromPath setAttrByPath; inherit (lib) mkIf; in { name, configRoot, settingsCfg, # str -> attrset modules ? [ ], owner ? "root", group ? "root", mode ? "0400", restartUnits ? [ "myunit.service" ], }: shb.test.runNixOSTest { name = "secret_${name}_${owner}_${group}_${mode}"; nodes.machine = { config, ... }: { imports = [ shb.test.baseImports ] ++ modules; config = lib.mkMerge [ (setAttrByPath configRoot { A = { request = { inherit owner group mode restartUnits ; }; settings = settingsCfg "secretA"; }; }) (mkIf (owner != "root") { users.users.${owner}.isNormalUser = true; }) (mkIf (group != "root") { users.groups.${group} = { }; }) ]; }; testScript = { nodes, ... }: let result = (getAttrFromPath configRoot nodes.machine)."A".result; in '' owner = machine.succeed("stat -c '%U' ${result.path}").strip() print(f"Got owner {owner}") if owner != "${owner}": raise Exception(f"Owner should be '${owner}' but got '{owner}'") group = machine.succeed("stat -c '%G' ${result.path}").strip() print(f"Got group {group}") if group != "${group}": raise Exception(f"Group should be '${group}' but got '{group}'") mode = str(int(machine.succeed("stat -c '%a' ${result.path}").strip())) print(f"Got mode {mode}") wantedMode = str(int("${mode}")) if mode != wantedMode: raise Exception(f"Mode should be '{wantedMode}' but got '{mode}'") content = machine.succeed("cat ${result.path}").strip() print(f"Got content {content}") if content != "secretA": raise Exception(f"Content should be 'secretA' but got '{content}'") ''; } ================================================ FILE: modules/contracts/secret.nix ================================================ { lib, shb, ... }: let inherit (lib) concatStringsSep literalMD mkOption optionalAttrs optionalString ; inherit (lib.types) listOf submodule str; inherit (shb) anyNotNull; in { mkRequest = { mode ? "0400", modeText ? null, owner ? "root", ownerText ? null, group ? "root", groupText ? null, restartUnits ? [ ], restartUnitsText ? null, }: mkOption { description = '' Request part of the secret contract. Options set by the requester module enforcing some properties the secret should have. ''; default = { inherit mode owner group restartUnits ; }; defaultText = optionalString (anyNotNull [ modeText ownerText groupText restartUnitsText ]) (literalMD '' { mode = ${if modeText != null then modeText else mode}; owner = ${if ownerText != null then ownerText else owner}; group = ${if groupText != null then groupText else group}; restartUnits = ${ if restartUnitsText != null then restartUnitsText else "[ " + concatStringsSep " " restartUnits + " ]" }; } ''); type = submodule { options = { mode = mkOption { description = '' Mode of the secret file. ''; type = str; default = mode; } // optionalAttrs (modeText != null) { defaultText = literalMD modeText; }; owner = mkOption ( { description = '' Linux user owning the secret file. ''; type = str; default = owner; } // optionalAttrs (ownerText != null) { defaultText = literalMD ownerText; } ); group = mkOption { description = '' Linux group owning the secret file. ''; type = str; default = group; } // optionalAttrs (groupText != null) { defaultText = literalMD groupText; }; restartUnits = mkOption ( { description = '' Systemd units to restart after the secret is updated. ''; type = listOf str; default = restartUnits; } // optionalAttrs (restartUnitsText != null) { defaultText = literalMD restartUnitsText; } ); }; }; }; mkResult = { path ? "/run/secrets/secret", pathText ? null, }: mkOption ( { description = '' Result part of the secret contract. Options set by the provider module that indicates where the secret can be found. ''; default = { inherit path; }; type = submodule { options = { path = mkOption { type = lib.types.path; description = '' Path to the file containing the secret generated out of band. This path will exist after deploying to a target host, it is not available through the nix store. ''; default = path; } // optionalAttrs (pathText != null) { defaultText = pathText; }; }; }; } // optionalAttrs (pathText != null) { defaultText = { path = pathText; }; } ); } ================================================ FILE: modules/contracts/ssl/docs/default.md ================================================ # SSL Generator Contract {#contract-ssl} This NixOS contract represents an SSL certificate generator. This contract is used to decouple generating an SSL certificate from using it. In practice, you can swap generators without updating modules depending on it. ## Contract Reference {#contract-ssl-options} These are all the options that are expected to exist for this contract to be respected. ```{=include=} options id-prefix: contracts-ssl-options- list-id: selfhostblocks-options source: @OPTIONS_JSON@ ``` ## Usage {#contract-ssl-usage} Let's assume a module implementing this contract is available under the `ssl` variable: ```nix let ssl = <...>; in ``` To use this module, we can reference the path where the certificate and the private key are located with: ```nix ssl.paths.cert ssl.paths.key ``` We can then configure Nginx to use those certificates: ```nix services.nginx.virtualHosts."example.com" = { onlySSL = true; sslCertificate = ssl.paths.cert; sslCertificateKey = ssl.paths.key; locations."/".extraConfig = '' add_header Content-Type text/plain; return 200 'It works!'; ''; }; ``` To make sure the Nginx webserver can find the generated file, we will make it wait for the certificate to the generated: ```nix systemd.services.nginx = { after = [ ssl.systemdService ]; requires = [ ssl.systemdService ]; }; ``` ## Provided Implementations {#contract-ssl-impl-shb} Multiple implementation are provided out of the box at [SSL block](blocks-ssl.html). ## Custom Implementation {#contract-ssl-impl-custom} To implement this contract, you must create a module that respects this contract. The following snippet shows an example. ```nix { lib, ... }: { options.my.generator = { paths = lib.mkOption { description = '' Paths where certs will be located. This option implements the SSL Generator contract. ''; type = contracts.ssl.certs-paths; default = { key = "/var/lib/my_generator/key.pem"; cert = "/var/lib/my_generator/cert.pem"; }; }; systemdService = lib.mkOption { description = '' Systemd oneshot service used to generate the certs. This option implements the SSL Generator contract. ''; type = lib.types.str; default = "my-generator.service"; }; # Other options needed for this implementation }; config = { # custom implementation goes here }; } ``` You can then create an instance of this generator: ```nix { my.generator = ...; } ``` And use it whenever a module expects something implementing this SSL generator contract: ```nix { config, ... }: { my.service.ssl = config.my.generator; } ``` ================================================ FILE: modules/contracts/ssl/dummyModule.nix ================================================ { lib, shb, ... }: { imports = [ ../../../lib/module.nix ]; options.shb.contracts.ssl = lib.mkOption { description = "Contract for SSL Certificate generator."; type = shb.contracts.ssl.certs; }; } ================================================ FILE: modules/contracts/ssl.nix ================================================ { lib, ... }: rec { certs-paths = lib.types.submodule { freeformType = lib.types.anything; options = { cert = lib.mkOption { type = lib.types.path; description = "Path to the cert file."; }; key = lib.mkOption { type = lib.types.path; description = "Path to the key file."; }; }; }; cas = lib.types.submodule { freeformType = lib.types.anything; options = { paths = lib.mkOption { description = '' Paths where the files for the CA will be located. This option is the contract output of the `shb.certs.cas` SSL block. ''; type = certs-paths; }; systemdService = lib.mkOption { description = '' Systemd oneshot service used to generate the CA. Ends with the `.service` suffix. Use this if downstream services must wait for the certificates to be generated before starting. ''; type = lib.types.str; example = "ca-generator.service"; }; }; }; certs = lib.types.submodule { freeformType = lib.types.anything; options = { paths = lib.mkOption { description = '' Paths where the files for the certificate will be located. This option is the contract output of the `shb.certs.certs` SSL block. ''; type = certs-paths; }; systemdService = lib.mkOption { description = '' Systemd oneshot service used to generate the certificate. Ends with the `.service` suffix. Use this if downstream services must wait for the certificates to be generated before starting. ''; type = lib.types.str; example = "cert-generator.service"; }; }; }; } ================================================ FILE: modules/services/arr/docs/default.md ================================================ # *Arr Service {#services-arr} Defined in [`/modules/services/arr.nix`](@REPO@/modules/services/arr.nix). This NixOS module sets up multiple [Servarr](https://wiki.servarr.com/) services. ## Features {#services-arr-features} Compared to the stock module from nixpkgs, this one sets up, in a fully declarative manner LDAP and SSO integration as well as the API key. ## Usage {#services-arr-usage} ### Initial Configuration {#services-arr-usage-configuration} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ```nix { shb.certs.certs.letsencrypt.${domain}.extraDomains = [ "moviesdl.${domain}" "seriesdl.${domain}" "subtitlesdl.${domain}" "booksdl.${domain}" "musicdl.${domain}" "indexer.${domain}" ]; shb.arr = { radarr = { inherit domain; enable = true; ssl = config.shb.certs.certs.letsencrypt.${domain}; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; sonarr = { inherit domain; enable = true; ssl = config.shb.certs.certs.letsencrypt."${domain}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; bazarr = { inherit domain; enable = true; ssl = config.shb.certs.certs.letsencrypt."${domain}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; readarr = { inherit domain; enable = true; ssl = config.shb.certs.certs.letsencrypt."${domain}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; lidarr = { inherit domain; enable = true; ssl = config.shb.certs.certs.letsencrypt."${domain}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; jackett = { inherit domain; enable = true; ssl = config.shb.certs.certs.letsencrypt."${domain}"; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; } ``` The user and admin LDAP groups are created automatically. ### API Keys {#services-arr-usage-apikeys} The API keys for each arr service can be created declaratively. First, generate one secret for each service with `nix run nixpkgs#openssl -- rand -hex 64` and store it in your secrets file (for example the SOPS file). Then, add the API key to each service: ```nix { shb.arr = { radarr = { settings = { ApiKey.source = config.shb.sops.secret."radarr/apikey".result.path; }; }; sonarr = { settings = { ApiKey.source = config.shb.sops.secret."sonarr/apikey".result.path; }; }; bazarr = { settings = { ApiKey.source = config.shb.sops.secret."bazarr/apikey".result.path; }; }; readarr = { settings = { ApiKey.source = config.shb.sops.secret."readarr/apikey".result.path; }; }; lidarr = { settings = { ApiKey.source = config.shb.sops.secret."lidarr/apikey".result.path; }; }; jackett = { settings = { ApiKey.source = config.shb.sops.secret."jackett/apikey".result.path; }; }; }; shb.sops.secret."radarr/apikey".request = { mode = "0440"; owner = "radarr"; group = "radarr"; restartUnits = [ "radarr.service" ]; }; shb.sops.secret."sonarr/apikey".request = { mode = "0440"; owner = "sonarr"; group = "sonarr"; restartUnits = [ "sonarr.service" ]; }; shb.sops.secret."bazarr/apikey".request = { mode = "0440"; owner = "bazarr"; group = "bazarr"; restartUnits = [ "bazarr.service" ]; }; shb.sops.secret."readarr/apikey".request = { mode = "0440"; owner = "readarr"; group = "readarr"; restartUnits = [ "readarr.service" ]; }; shb.sops.secret."lidarr/apikey".request = { mode = "0440"; owner = "lidarr"; group = "lidarr"; restartUnits = [ "lidarr.service" ]; }; shb.sops.secret."jackett/apikey".request = { mode = "0440"; owner = "jackett"; group = "jackett"; restartUnits = [ "jackett.service" ]; }; } ``` ### Application Dashboard {#services-arr-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the various dashboard options. For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Media.services.Radarr = { sortOrder = 10; dashboard.request = config.shb.arr.radarr.dashboard.request; apiKey.result = config.shb.sops.secret."radarr/homepageApiKey".result; }; shb.sops.secret."radarr/homepageApiKey" = { settings.key = "radarr/apikey"; request = config.shb.homepage.servicesGroups.Media.services.Radarr.apiKey.request; }; shb.homepage.servicesGroups.Media.services.Sonarr = { sortOrder = 11; dashboard.request = config.shb.arr.sonarr.dashboard.request; apiKey.result = config.shb.sops.secret."sonarr/homepageApiKey".result; }; shb.sops.secret."sonarr/homepageApiKey" = { settings.key = "sonarr/apikey"; request = config.shb.homepage.servicesGroups.Media.services.Sonarr.apiKey.request; }; shb.homepage.servicesGroups.Media.services.Bazarr = { sortOrder = 12; dashboard.request = config.shb.arr.bazarr.dashboard.request; apiKey.result = config.shb.sops.secret."bazarr/homepageApiKey".result; }; shb.sops.secret."bazarr/homepageApiKey" = { settings.key = "bazarr/apikey"; request = config.shb.homepage.servicesGroups.Media.services.Bazarr.apiKey.request; }; shb.homepage.servicesGroups.Media.services.Readarr = { sortOrder = 13; dashboard.request = config.shb.arr.readarr.dashboard.request; apiKey.result = config.shb.sops.secret."readarr/homepageApiKey".result; }; shb.sops.secret."readarr/homepageApiKey" = { settings.key = "readarr/apikey"; request = config.shb.homepage.servicesGroups.Media.services.Readarr.apiKey.request; }; shb.homepage.servicesGroups.Media.services.Lidarr = { sortOrder = 14; dashboard.request = config.shb.arr.lidarr.dashboard.request; apiKey.result = config.shb.sops.secret."lidarr/homepageApiKey".result; }; shb.sops.secret."lidarr/homepageApiKey" = { settings.key = "lidarr/apikey"; request = config.shb.homepage.servicesGroups.Media.services.Lidarr.apiKey.request; }; shb.homepage.servicesGroups.Media.services.Jackett = { sortOrder = 15; dashboard.request = config.shb.arr.jackett.dashboard.request; apiKey.result = config.shb.sops.secret."jackett/homepageApiKey".result; }; shb.sops.secret."jackett/homepageApiKey" = { settings.key = "jackett/apikey"; request = config.shb.homepage.servicesGroups.Media.services.Jackett.apiKey.request; }; } ``` This example reuses the API keys generated declaratively from the previous section. ### Jackett Proxy {#services-arr-usage-jackett-proxy} The Jackett service can be made to use a proxy with: ```nix { shb.arr.jackett = { settings = { ProxyType = "0"; ProxyUrl = "127.0.0.1:1234"; }; }; }; ``` ## Options Reference {#services-arr-options} ```{=include=} options id-prefix: services-arr-options- list-id: selfhostblocks-service-arr-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/arr.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.arr; apps = { radarr = { settingsFormat = shb.formatXML { enclosingRoot = "Config"; }; moreOptions = { settings = lib.mkOption { description = "Specific options for radarr."; default = { }; type = lib.types.submodule { freeformType = apps.radarr.settingsFormat.type; options = { ApiKey = lib.mkOption { type = shb.secretFileType; description = "Path to api key secret file."; }; LogLevel = lib.mkOption { type = lib.types.enum [ "debug" "info" ]; description = "Log level."; default = "info"; }; Port = lib.mkOption { type = lib.types.port; description = "Port on which radarr listens to incoming requests."; default = 7878; }; AnalyticsEnabled = lib.mkOption { type = lib.types.bool; description = "Wether to send anonymous data or not."; default = false; }; BindAddress = lib.mkOption { type = lib.types.str; internal = true; default = "127.0.0.1"; }; UrlBase = lib.mkOption { type = lib.types.str; internal = true; default = ""; }; EnableSsl = lib.mkOption { type = lib.types.bool; internal = true; default = false; }; AuthenticationMethod = lib.mkOption { type = lib.types.str; internal = true; default = "External"; }; AuthenticationRequired = lib.mkOption { type = lib.types.str; internal = true; default = "Enabled"; }; }; }; }; }; }; sonarr = { settingsFormat = shb.formatXML { enclosingRoot = "Config"; }; moreOptions = { settings = lib.mkOption { description = "Specific options for sonarr."; default = { }; type = lib.types.submodule { freeformType = apps.sonarr.settingsFormat.type; options = { ApiKey = lib.mkOption { type = shb.secretFileType; description = "Path to api key secret file."; }; LogLevel = lib.mkOption { type = lib.types.enum [ "debug" "info" ]; description = "Log level."; default = "info"; }; Port = lib.mkOption { type = lib.types.port; description = "Port on which sonarr listens to incoming requests."; default = 8989; }; BindAddress = lib.mkOption { type = lib.types.str; internal = true; default = "127.0.0.1"; }; UrlBase = lib.mkOption { type = lib.types.str; internal = true; default = ""; }; EnableSsl = lib.mkOption { type = lib.types.bool; internal = true; default = false; }; AuthenticationMethod = lib.mkOption { type = lib.types.str; internal = true; default = "External"; }; AuthenticationRequired = lib.mkOption { type = lib.types.str; internal = true; default = "Enabled"; }; }; }; }; }; }; bazarr = { settingsFormat = shb.formatXML { enclosingRoot = "Config"; }; moreOptions = { settings = lib.mkOption { description = "Specific options for bazarr."; default = { }; type = lib.types.submodule { freeformType = apps.bazarr.settingsFormat.type; options = { LogLevel = lib.mkOption { type = lib.types.enum [ "debug" "info" ]; description = "Log level."; default = "info"; }; ApiKey = lib.mkOption { type = shb.secretFileType; description = "Path to api key secret file."; }; Port = lib.mkOption { type = lib.types.port; description = "Port on which bazarr listens to incoming requests."; default = 6767; readOnly = true; }; }; }; }; }; }; readarr = { settingsFormat = shb.formatXML { enclosingRoot = "Config"; }; moreOptions = { settings = lib.mkOption { description = "Specific options for readarr."; default = { }; type = lib.types.submodule { freeformType = apps.readarr.settingsFormat.type; options = { LogLevel = lib.mkOption { type = lib.types.enum [ "debug" "info" ]; description = "Log level."; default = "info"; }; ApiKey = lib.mkOption { type = shb.secretFileType; description = "Path to api key secret file."; }; Port = lib.mkOption { type = lib.types.port; description = "Port on which readarr listens to incoming requests."; default = 8787; }; }; }; }; }; }; lidarr = { settingsFormat = shb.formatXML { enclosingRoot = "Config"; }; moreOptions = { settings = lib.mkOption { description = "Specific options for lidarr."; default = { }; type = lib.types.submodule { freeformType = apps.lidarr.settingsFormat.type; options = { LogLevel = lib.mkOption { type = lib.types.enum [ "debug" "info" ]; description = "Log level."; default = "info"; }; ApiKey = lib.mkOption { type = shb.secretFileType; description = "Path to api key secret file."; }; Port = lib.mkOption { type = lib.types.port; description = "Port on which lidarr listens to incoming requests."; default = 8686; }; }; }; }; }; }; jackett = { settingsFormat = pkgs.formats.json { }; moreOptions = { settings = lib.mkOption { description = "Specific options for jackett."; default = { }; type = lib.types.submodule { freeformType = apps.jackett.settingsFormat.type; options = { ApiKey = lib.mkOption { type = shb.secretFileType; description = "Path to api key secret file."; }; FlareSolverrUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "FlareSolverr endpoint."; default = null; }; OmdbApiKey = lib.mkOption { type = lib.types.nullOr shb.secretFileType; description = "File containing the Open Movie Database API key."; default = null; }; ProxyType = lib.mkOption { type = lib.types.enum [ "-1" "0" "1" "2" ]; default = "-1"; description = '' -1 = disabled 0 = HTTP 1 = SOCKS4 2 = SOCKS5 ''; }; ProxyUrl = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "URL of the proxy. Ignored if ProxyType is set to -1"; default = null; }; ProxyPort = lib.mkOption { type = lib.types.nullOr lib.types.port; description = "Port of the proxy. Ignored if ProxyType is set to -1"; default = null; }; Port = lib.mkOption { type = lib.types.port; description = "Port on which jackett listens to incoming requests."; default = 9117; readOnly = true; }; AllowExternal = lib.mkOption { type = lib.types.bool; internal = true; default = false; }; UpdateDisabled = lib.mkOption { type = lib.types.bool; internal = true; default = true; }; }; }; }; }; }; }; vhosts = { extraBypassResources ? [ ], }: c: { inherit (c) subdomain domain authEndpoint ssl ; upstream = "http://127.0.0.1:${toString c.settings.Port}"; autheliaRules = lib.optionals (!(isNull c.authEndpoint)) [ { domain = "${c.subdomain}.${c.domain}"; policy = "bypass"; resources = extraBypassResources ++ [ "^/api.*" "^/feed.*" ]; } { domain = "${c.subdomain}.${c.domain}"; policy = "two_factor"; subject = [ "group:${c.ldapUserGroup}" ]; } ]; }; appOption = name: c: lib.nameValuePair name ( lib.mkOption { description = "Configuration for ${name}"; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption name; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which ${name} will be served."; example = name; }; domain = lib.mkOption { type = lib.types.str; description = "Domain under which ${name} will be served."; example = "example.com"; }; dataDir = lib.mkOption { type = lib.types.str; description = "Directory where ${name} stores data."; default = "/var/lib/${name}"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; ldapUserGroup = lib.mkOption { description = '' LDAP group a user must belong to be able to login. Note that all users are admins too. ''; type = lib.types.str; default = "arr_user"; }; authEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "Endpoint to the SSO provider. Leave null to not have SSO configured."; example = "https://authelia.example.com"; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = name; sourceDirectories = [ cfg.${name}.dataDir ]; excludePatterns = [ ".db-shm" ".db-wal" ".mono" ]; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.${name}.subdomain}.${cfg.${name}.domain}"; externalUrlText = "https://\${config.shb.arr.${name}.subdomain}.\${config.shb.arr.${name}.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.${name}.settings.Port}"; }; }; }; } // (c.moreOptions or { }); }; } ); in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ../blocks/lldap.nix ]; options.shb.arr = lib.listToAttrs (lib.mapAttrsToList appOption apps); config = lib.mkMerge [ (lib.mkIf cfg.radarr.enable ( let cfg' = cfg.radarr; isSSOEnabled = !(isNull cfg'.authEndpoint); in { services.nginx.enable = true; services.radarr = { enable = true; dataDir = cfg'.dataDir; }; systemd.services.radarr.preStart = shb.replaceSecrets { userConfig = cfg'.settings // (lib.optionalAttrs isSSOEnabled { AuthenticationRequired = "DisabledForLocalAddresses"; AuthenticationMethod = "External"; }); resultPath = "${cfg'.dataDir}/config.xml"; generator = shb.replaceSecretsFormatAdapter apps.radarr.settingsFormat; }; shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.lldap.ensureGroups = { ${cfg'.ldapUserGroup} = { }; }; } )) (lib.mkIf cfg.sonarr.enable ( let cfg' = cfg.sonarr; isSSOEnabled = !(isNull cfg'.authEndpoint); in { systemd.tmpfiles.rules = [ "d ${cfg'.dataDir} 0700 ${config.services.sonarr.user} ${config.services.sonarr.user}" ]; services.nginx.enable = true; services.sonarr = { enable = true; dataDir = cfg'.dataDir; }; users.users.sonarr = { extraGroups = [ "media" ]; }; systemd.services.sonarr.preStart = shb.replaceSecrets { userConfig = cfg'.settings // (lib.optionalAttrs isSSOEnabled { AuthenticationRequired = "DisabledForLocalAddresses"; AuthenticationMethod = "External"; }); resultPath = "${cfg'.dataDir}/config.xml"; generator = apps.sonarr.settingsFormat.generate; }; shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.lldap.ensureGroups = { ${cfg'.ldapUserGroup} = { }; }; } )) (lib.mkIf cfg.bazarr.enable ( let cfg' = cfg.bazarr; isSSOEnabled = !(isNull cfg'.authEndpoint); in { services.bazarr = { enable = true; dataDir = cfg'.dataDir; listenPort = cfg'.settings.Port; }; users.users.bazarr = { extraGroups = [ "media" ]; }; # This is actually not working. Bazarr uses a config file in dataDir/config/config.yaml # which includes all configuration so we must somehow merge our declarative config with it. # It's doable but will take some time. Help is welcomed. # # systemd.services.bazarr.preStart = shb.replaceSecrets { # userConfig = # cfg'.settings # // (lib.optionalAttrs isSSOEnabled { # AuthenticationRequired = "DisabledForLocalAddresses"; # AuthenticationMethod = "External"; # }); # resultPath = "${cfg'.dataDir}/config.xml"; # generator = apps.bazarr.settingsFormat.generate; # }; shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.lldap.ensureGroups = { ${cfg'.ldapUserGroup} = { }; }; } )) (lib.mkIf cfg.readarr.enable ( let cfg' = cfg.readarr; isSSOEnabled = !(isNull cfg'.authEndpoint); in { services.readarr = { enable = true; dataDir = cfg'.dataDir; }; users.users.readarr = { extraGroups = [ "media" ]; }; systemd.services.readarr.preStart = shb.replaceSecrets { userConfig = cfg'.settings // (lib.optionalAttrs isSSOEnabled { AuthenticationRequired = "DisabledForLocalAddresses"; AuthenticationMethod = "External"; }); resultPath = "${cfg'.dataDir}/config.xml"; generator = apps.readarr.settingsFormat.generate; }; shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.lldap.ensureGroups = { ${cfg'.ldapUserGroup} = { }; }; } )) (lib.mkIf cfg.lidarr.enable ( let cfg' = cfg.lidarr; isSSOEnabled = !(isNull cfg'.authEndpoint); in { services.lidarr = { enable = true; dataDir = cfg'.dataDir; }; users.users.lidarr = { extraGroups = [ "media" ]; }; systemd.services.lidarr.preStart = shb.replaceSecrets { userConfig = cfg'.settings // (lib.optionalAttrs isSSOEnabled { AuthenticationRequired = "DisabledForLocalAddresses"; AuthenticationMethod = "External"; }); resultPath = "${cfg'.dataDir}/config.xml"; generator = apps.lidarr.settingsFormat.generate; }; shb.nginx.vhosts = [ (vhosts { } cfg') ]; shb.lldap.ensureGroups = { ${cfg'.ldapUserGroup} = { }; }; } )) (lib.mkIf cfg.jackett.enable ( let cfg' = cfg.jackett; in { services.jackett = { enable = true; dataDir = cfg'.dataDir; }; # TODO: avoid implicitly relying on the media group users.users.jackett = { extraGroups = [ "media" ]; }; systemd.services.jackett.preStart = shb.replaceSecrets { userConfig = shb.renameAttrName cfg'.settings "ApiKey" "APIKey"; resultPath = "${cfg'.dataDir}/ServerConfig.json"; generator = apps.jackett.settingsFormat.generate; }; shb.nginx.vhosts = [ (vhosts { extraBypassResources = [ "^/dl.*" ]; } cfg') ]; shb.lldap.ensureGroups = { ${cfg'.ldapUserGroup} = { }; }; } )) ]; } ================================================ FILE: modules/services/audiobookshelf/docs/default.md ================================================ # Audiobookshelf Service {#services-audiobookshelf} Defined in [`/modules/services/audiobookshelf.nix`](@REPO@/modules/services/audiobookshelf.nix). This NixOS module is a service that sets up a [Audiobookshelf](https://www.audiobookshelf.org/) instance. ## Features {#services-audiobookshelf-features} - Declarative selection of listening port. - Access through [subdomain](#services-audiobookshelf-options-shb.audiobookshelf.subdomain) using reverse proxy. [Manual](#services-audiobookshelf-usage-configuration). - Access through [HTTPS](#services-audiobookshelf-options-shb.audiobookshelf.ssl) using reverse proxy. [Manual](#services-audiobookshelf-usage-https). - Declarative [SSO](#services-audiobookshelf-options-shb.audiobookshelf.sso) configuration (Manual setup in app required). [Manual](#services-audiobookshelf-usage-sso). - [Backup](#services-audiobookshelf-options-shb.audiobookshelf.backup) through the [backup block](./blocks-backup.html). [Manual](#services-audiobookshelf-usage-backup). ## Usage {#services-audiobookshelf-usage} ### Login Upon first login, Audiobookshelf will ask you to create a root user. This user will be used to set up [SSO]{#services-audiobookshelf-usage-sso}, or to provision admin privileges to other users. ### With SSO Support {#services-audiobookshelf-usage-sso} :::: {.note} Some manual setup in the app is required. :::: We will use the [SSO block][] provided by Self Host Blocks. Assuming it [has been set already][SSO block setup], add the following configuration: [SSO block]: blocks-sso.html [SSO block setup]: blocks-sso.html#blocks-sso-global-setup ```nix shb.audiobookshelf.sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secretFile = ; secretFileForAuthelia = ; }; ``` The `shb.audiobookshelf.sso.secretFile` and `shb.audiobookshelf.sso.secretFileForAuthelia` options must have the same content. The former is a file that must be owned by the `audiobookshelf` user while the latter must be owned by the `authelia` user. I want to avoid needing to define the same secret twice with a future secrets SHB block. In the Audiobookshelf app, you can now log in with your Audiobookshelf root user and go to "Settings->Authentication", and then enable "OpenID Connect Authentication" and enter your "Issuer URL" (e.g. `https://auth.example.com`). Then click the "Auto-populate" button. Next, paste in the client secret (from `secrets.yaml`). Then set up "Client ID" to be `audiobookshelf`. Make sure to also select `None` in "Subfolder for Redirect URLs". Then make sure to tick "Auto Register". You can also tick "Auto Launch" to make Audiobookshelf automatically redirect users to the SSO sign-in page instead. This can later be circumvented by accessing `https:///login?autoLaunch=0`, if you're having SSO issues. Finally, set "Group Claim" to `audiobookshelf_groups`. This enables Audiobookshelf to allow access only to users belonging to `userGroup` (default `audiobookshelf_user`), and to grant admin privileges to members of `adminUserGroup` (default `audiobookshelf_admin`). Save the settings and restart the Audiobookshelf service (`systemctl restart audiobookshelf.service`). You should now be able to log in with users belonging to either of the aforementioned allowed groups. ================================================ FILE: modules/services/audiobookshelf.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.audiobookshelf; fqdn = "${cfg.subdomain}.${cfg.domain}"; roleClaim = "audiobookshelf_groups"; in { options.shb.audiobookshelf = { enable = lib.mkEnableOption "selfhostblocks.audiobookshelf"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which audiobookshelf will be served."; example = "abs"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which audiobookshelf will be served."; example = "mydomain.com"; }; webPort = lib.mkOption { type = lib.types.int; description = "Audiobookshelf web port"; default = 8113; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; extraServiceConfig = lib.mkOption { type = lib.types.attrsOf lib.types.str; description = "Extra configuration given to the systemd service file."; default = { }; example = lib.literalExpression '' { MemoryHigh = "512M"; MemoryMax = "900M"; } ''; }; sso = lib.mkOption { description = "SSO configuration."; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "SSO"; provider = lib.mkOption { type = lib.types.str; description = "OIDC provider name"; default = "Authelia"; }; endpoint = lib.mkOption { type = lib.types.str; description = "OIDC endpoint for SSO"; example = "https://authelia.example.com"; }; clientID = lib.mkOption { type = lib.types.str; description = "Client ID for the OIDC endpoint"; default = "audiobookshelf"; }; adminUserGroup = lib.mkOption { type = lib.types.str; description = "OIDC admin group"; default = "audiobookshelf_admin"; }; userGroup = lib.mkOption { type = lib.types.str; description = "OIDC user group"; default = "audiobookshelf_user"; }; authorization_policy = lib.mkOption { type = lib.types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; sharedSecret = lib.mkOption { description = "OIDC shared secret for Audiobookshelf."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "audiobookshelf"; group = "audiobookshelf"; restartUnits = [ "audiobookshelfd.service" ]; }; }; }; sharedSecretForAuthelia = lib.mkOption { description = "OIDC shared secret for Authelia."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; ownerText = "config.shb.authelia.autheliaUser"; owner = config.shb.authelia.autheliaUser; }; }; }; }; }; }; backup = lib.mkOption { description = '' Backup configuration. ''; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "audiobookshelf"; sourceDirectories = [ "/var/lib/audiobookshelf" ]; }; }; }; logLevel = lib.mkOption { type = lib.types.nullOr ( lib.types.enum [ "critical" "error" "warning" "info" "debug" ] ); description = "Enable logging."; default = false; example = true; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.audiobookshelf.subdomain}.\${config.shb.audiobookshelf.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.webPort}"; }; }; }; }; config = lib.mkIf cfg.enable ( lib.mkMerge [ { services.audiobookshelf = { enable = true; openFirewall = true; dataDir = "audiobookshelf"; host = "127.0.0.1"; port = cfg.webPort; }; services.nginx.enable = true; services.nginx.virtualHosts."${fqdn}" = { http2 = true; forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; # https://github.com/advplyr/audiobookshelf#nginx-reverse-proxy extraConfig = '' set $audiobookshelf 127.0.0.1; location / { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_http_version 1.1; proxy_pass http://$audiobookshelf:${builtins.toString cfg.webPort}; proxy_redirect http:// https://; } ''; }; shb.authelia.extraDefinitions = { user_attributes.${roleClaim}.expression = ''"${cfg.sso.adminUserGroup}" in groups ? ["admin"] : ("${cfg.sso.userGroup}" in groups ? ["user"] : [""])''; }; shb.authelia.extraOidcClaimsPolicies.${roleClaim} = { custom_claims = { "${roleClaim}" = { }; }; }; shb.authelia.extraOidcScopes."${roleClaim}" = { claims = [ "${roleClaim}" ]; }; shb.authelia.oidcClients = lib.lists.optionals cfg.sso.enable [ { client_id = cfg.sso.clientID; client_name = "Audiobookshelf"; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; claims_policy = "${roleClaim}"; public = false; authorization_policy = cfg.sso.authorization_policy; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/auth/openid/callback" "https://${cfg.subdomain}.${cfg.domain}/auth/openid/mobile-redirect" ]; scopes = [ "openid" "profile" "email" "groups" "${roleClaim}" ]; require_pkce = true; pkce_challenge_method = "S256"; userinfo_signed_response_alg = "none"; token_endpoint_auth_method = "client_secret_basic"; } ]; } { systemd.services.audiobookshelfd.serviceConfig = cfg.extraServiceConfig; } ] ); } ================================================ FILE: modules/services/deluge/dashboard/Torrents.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "datasource", "uid": "grafana" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "target": { "limit": 100, "matchAny": false, "tags": [], "type": "dashboard" }, "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": 12, "links": [], "panels": [ { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 15, "panels": [], "title": "Torrent", "type": "row" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "decimals": 1, "fieldMinMax": false, "mappings": [], "min": 0, "thresholds": { "mode": "percentage", "steps": [ { "color": "green", "value": null } ] }, "unit": "bytes" }, "overrides": [] }, "gridPos": { "h": 5, "w": 3, "x": 0, "y": 1 }, "id": 19, "maxPerRow": 3, "options": { "minVizHeight": 75, "minVizWidth": 75, "orientation": "auto", "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, "showThresholdLabels": false, "showThresholdMarkers": true, "sizing": "auto", "text": {} }, "pluginVersion": "11.4.0", "repeat": "mountpoint", "repeatDirection": "v", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "node_filesystem_size_bytes{hostname=~\"$hostname\",mountpoint=\"$mountpoint\"} - node_filesystem_free_bytes{hostname=~\"$hostname\",mountpoint=\"$mountpoint\"}", "instant": false, "legendFormat": "__auto", "range": true, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "node_filesystem_size_bytes{hostname=~\"$hostname\",mountpoint=\"$mountpoint\"}", "hide": false, "instant": false, "legendFormat": "__auto", "range": true, "refId": "B" }, { "conditions": [ { "evaluator": { "params": [ 0, 0 ], "type": "gt" }, "query": { "params": [] }, "reducer": { "params": [], "type": "last" }, "type": "query" } ], "datasource": { "name": "Expression", "type": "__expr__", "uid": "__expr__" }, "expression": "$B*0.95", "hide": false, "refId": "D", "type": "math" } ], "title": "$mountpoint Used Space", "transformations": [ { "id": "configFromData", "options": { "applyTo": { "id": "byFrameRefID", "options": "A" }, "configRefId": "B", "mappings": [ { "fieldName": "Time", "handlerKey": "__ignore" }, { "fieldName": "device", "handlerKey": "__ignore" }, { "fieldName": "domain", "handlerKey": "__ignore" }, { "fieldName": "fstype", "handlerKey": "__ignore" }, { "fieldName": "hostname", "handlerKey": "__ignore" }, { "fieldName": "instance", "handlerKey": "__ignore" }, { "fieldName": "job", "handlerKey": "__ignore" }, { "fieldName": "mountpoint", "handlerKey": "__ignore" }, { "fieldName": "{__name__=\"node_filesystem_size_bytes\", device=\"data/movies\", fstype=\"zfs\", instance=\"127.0.0.1:9112\", job=\"node\", mountpoint=\"/srv/movies\"}", "handlerKey": "max" }, { "fieldName": "node_filesystem_size_bytes {__name__=\"node_filesystem_size_bytes\", device=\"data/movies\", fstype=\"zfs\", instance=\"127.0.0.1:9112\", job=\"node\", mountpoint=\"/srv/movies\"}", "handlerKey": "max" } ] } }, { "id": "configFromData", "options": { "applyTo": { "id": "byFrameRefID", "options": "A" }, "configRefId": "D", "mappings": [ { "fieldName": "D {__name__=\"node_filesystem_size_bytes\", device=\"data/movies\", fstype=\"zfs\", instance=\"127.0.0.1:9112\", job=\"node\", mountpoint=\"/srv/movies\"}", "handlerArguments": { "threshold": { "color": "red" } }, "handlerKey": "threshold1" } ] } } ], "type": "gauge" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "yellow", "value": null } ] } }, "overrides": [] }, "gridPos": { "h": 15, "w": 4, "x": 3, "y": 1 }, "id": 17, "options": { "colorMode": "none", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "percentChangeColorMode": "standard", "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, "showPercentChange": false, "textMode": "auto", "wideLayout": true }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": true, "expr": "deluge_torrents{hostname=~\"$hostname\"}", "instant": false, "interval": "", "legendFormat": "{{state}}", "refId": "A" } ], "title": "Torrent States", "type": "stat" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "max": 1, "min": 0, "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "percentunit" }, "overrides": [] }, "gridPos": { "h": 7, "w": 17, "x": 7, "y": 1 }, "id": 23, "options": { "legend": { "calcs": [ "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 350 }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "deluge_torrent_done_total{hostname=~\"$hostname\",state=\"downloading\",name=~\"$torrent\"} / deluge_torrent_size_total{hostname=~\"$hostname\",state=\"downloading\",name=~\"$torrent\"}", "legendFormat": "{{name}}", "range": true, "refId": "A" } ], "title": "In Progress Downloads", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [ { "options": { "-1": { "index": 0, "text": "Never" } }, "type": "value" } ], "max": 86400, "thresholds": { "mode": "absolute", "steps": [ { "color": "semi-dark-red", "value": null }, { "color": "semi-dark-green", "value": 0 }, { "color": "semi-dark-orange", "value": 86400 } ] }, "unit": "s" }, "overrides": [] }, "gridPos": { "h": 8, "w": 6, "x": 7, "y": 8 }, "id": 31, "options": { "displayMode": "basic", "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false }, "maxVizHeight": 300, "minVizHeight": 16, "minVizWidth": 8, "namePlacement": "auto", "orientation": "horizontal", "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, "showUnfilled": true, "sizing": "auto", "valueMode": "color" }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "deluge_torrent_time_since_download{hostname=~\"$hostname\",state=\"downloading\",name=~\"$torrent\"}", "instant": true, "interval": "", "legendFormat": "{{name}}", "refId": "A" } ], "title": "Last Download", "transformations": [ { "id": "seriesToRows", "options": {} }, { "id": "sortBy", "options": { "fields": {}, "sort": [ { "desc": false, "field": "Value" } ] } }, { "id": "rowsToFields", "options": { "mappings": [ { "fieldName": "Time", "handlerKey": "__ignore" }, { "fieldName": "Value", "handlerKey": "field.value" }, { "fieldName": "Metric", "handlerKey": "field.name" } ] } } ], "type": "bargauge" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "mappings": [ { "options": { "1642487291": { "color": "semi-dark-red", "index": 0, "text": "Never" } }, "type": "value" } ], "max": 3600, "thresholds": { "mode": "absolute", "steps": [ { "color": "semi-dark-green", "value": null }, { "color": "#EAB839", "value": 86400 }, { "color": "semi-dark-red", "value": 1642487290 } ] }, "unit": "s" }, "overrides": [] }, "gridPos": { "h": 8, "w": 6, "x": 13, "y": 8 }, "id": 29, "options": { "displayMode": "basic", "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false }, "maxVizHeight": 300, "minVizHeight": 16, "minVizWidth": 8, "namePlacement": "auto", "orientation": "horizontal", "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, "showUnfilled": true, "sizing": "auto", "valueMode": "color" }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": false, "expr": "time()-deluge_torrent_last_seen_complete{hostname=~\"$hostname\",state=\"downloading\",name=~\"$torrent\"}", "instant": true, "interval": "", "legendFormat": "{{name}}", "refId": "A" } ], "title": "Last Seen Completed", "transformations": [ { "id": "seriesToRows", "options": {} }, { "id": "sortBy", "options": { "fields": {}, "sort": [ { "desc": true, "field": "Value" } ] } }, { "id": "rowsToFields", "options": { "mappings": [ { "fieldName": "Time", "handlerKey": "__ignore" }, { "fieldName": "Value", "handlerKey": "field.value" }, { "fieldName": "Metric", "handlerKey": "field.name" } ] } } ], "type": "bargauge" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "Bps" }, "overrides": [] }, "gridPos": { "h": 8, "w": 5, "x": 19, "y": 8 }, "id": 35, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "exemplar": true, "expr": "avg by(device) (rate(node_network_receive_bytes_total{hostname=~\"$hostname\",device=~\"tun.*\"}[5m]))", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "interval": "", "legendFormat": "in: {{ device }}", "range": true, "refId": "A", "useBackend": false }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": true, "expr": "-avg by(device) (rate(node_network_transmit_bytes_total{device=~\"tun.*\"}[5m]))", "hide": false, "interval": "", "legendFormat": "out: {{ device }}", "range": true, "refId": "B" } ], "title": "VPN Network I/O", "type": "timeseries" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 16 }, "id": 9, "panels": [], "title": "Services", "type": "row" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 4, "w": 8, "x": 0, "y": 17 }, "id": 6, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "exemplar": true, "expr": "netdata_systemd_service_unit_state_state_average{hostname=~\"$hostname\",unit_name=~\"deluged|delugeweb|openvpn.+\",dimension=\"active\"}", "interval": "", "legendFormat": "{{unit_name}}", "range": true, "refId": "A" } ], "title": "Services Up", "type": "timeseries" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 9, "w": 16, "x": 8, "y": 17 }, "id": 2, "options": { "dedupStrategy": "exact", "enableLogDetails": false, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"deluged.service\",level=~\"$level\"}", "queryType": "range", "refId": "A" } ], "title": "Deluge Logs", "type": "logs" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 5, "w": 4, "x": 0, "y": 21 }, "id": 4, "options": { "dedupStrategy": "exact", "enableLogDetails": false, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": false, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"deluged.service\"} |= \"on_alert_external_ip\" | regexp \".+on_alert_external_ip: (?P.+)\" | line_format \"{{.ip}}\"", "queryType": "range", "refId": "A" } ], "title": "Latest External IPs", "type": "logs" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 5, "w": 4, "x": 4, "y": 21 }, "id": 13, "options": { "dedupStrategy": "exact", "enableLogDetails": false, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": false, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=~\"openvpn.+.service\"} |= \"config -s listen_interface\" | pattern \"<_> listen_interface '\" | line_format \"{{.ip}}\"", "queryType": "range", "refId": "A" } ], "title": "Latest Interface IPs", "type": "logs" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 9, "w": 16, "x": 8, "y": 26 }, "id": 7, "options": { "dedupStrategy": "exact", "enableLogDetails": false, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": true, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=~\"openvpn.+.service\",level=~\"$level\"}", "legendFormat": "", "queryType": "range", "refId": "A" } ], "title": "VPN Logs", "type": "logs" } ], "preload": false, "refresh": "10s", "schemaVersion": 40, "tags": [], "templating": { "list": [ { "current": { "text": [ "$__all" ], "value": [ "$__all" ] }, "hide": 2, "includeAll": true, "multi": true, "name": "mountpoint", "options": [ { "selected": false, "text": "/srv/movies", "value": "/srv/movies" }, { "selected": false, "text": "/srv/music", "value": "/srv/music" }, { "selected": false, "text": "/srv/series", "value": "/srv/series" } ], "query": "/srv/movies,/srv/music,/srv/series", "type": "custom" }, { "current": { "text": "baryum", "value": "baryum" }, "definition": "label_values(up,hostname)", "name": "hostname", "options": [], "query": { "qryType": 1, "query": "label_values(up,hostname)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "type": "query" }, { "current": { "text": "All", "value": "$__all" }, "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "definition": "deluge_torrent_done_total", "includeAll": true, "multi": true, "name": "torrent", "options": [], "query": { "qryType": 4, "query": "deluge_torrent_done_total", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 2, "regex": "/.*name=\"(?[^\"]+)\".*/", "type": "query" } ] }, "time": { "from": "now-1h", "to": "now" }, "timepicker": {}, "timezone": "", "title": "Torrents", "uid": "Bg5L6T17k", "version": 22, "weekStart": "" } ================================================ FILE: modules/services/deluge.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.deluge; fqdn = "${cfg.subdomain}.${cfg.domain}"; authGenerator = users: let genLine = name: { password, priority ? 10, }: "${name}:${password}:${toString priority}"; lines = lib.mapAttrsToList genLine users; in lib.concatStringsSep "\n" lines; in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ../blocks/monitoring.nix ]; options.shb.deluge = { enable = lib.mkEnableOption "the SHB Deluge service"; enableDashboard = lib.mkEnableOption "the Torrents SHB monitoring dashboard" // { default = true; }; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which deluge will be served."; example = "ha"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which deluge will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; dataDir = lib.mkOption { type = lib.types.str; description = "Path where all configuration and state is stored."; default = "/var/lib/deluge"; }; daemonPort = lib.mkOption { type = lib.types.int; description = "Deluge daemon port"; default = 58846; }; daemonListenPorts = lib.mkOption { type = lib.types.listOf lib.types.int; description = "Deluge daemon listen ports"; default = [ 6881 6889 ]; }; webPort = lib.mkOption { type = lib.types.int; description = "Deluge web port"; default = 8112; }; proxyPort = lib.mkOption { description = "If not null, sets up a deluge to forward all traffic to the Proxy listening at that port."; type = lib.types.nullOr lib.types.int; default = null; }; outgoingInterface = lib.mkOption { description = "If not null, sets up a deluge to bind all outgoing traffic to the given interface."; type = lib.types.nullOr lib.types.str; default = null; }; settings = lib.mkOption { description = "Deluge operational settings."; type = lib.types.submodule { options = { downloadLocation = lib.mkOption { type = lib.types.str; description = "Folder where torrents gets downloaded"; example = "/srv/torrents"; }; max_active_limit = lib.mkOption { type = lib.types.int; description = "Maximum Active Limit"; default = 200; }; max_active_downloading = lib.mkOption { type = lib.types.int; description = "Maximum Active Downloading"; default = 30; }; max_active_seeding = lib.mkOption { type = lib.types.int; description = "Maximum Active Seeding"; default = 100; }; max_connections_global = lib.mkOption { type = lib.types.int; description = "Maximum Connections Global"; default = 200; }; max_connections_per_torrent = lib.mkOption { type = lib.types.int; description = "Maximum Connections Per Torrent"; default = 50; }; max_download_speed = lib.mkOption { type = lib.types.int; description = "Maximum Download Speed"; default = 1000; }; max_download_speed_per_torrent = lib.mkOption { type = lib.types.int; description = "Maximum Download Speed Per Torrent"; default = -1; }; max_upload_slots_global = lib.mkOption { type = lib.types.int; description = "Maximum Upload Slots Global"; default = 100; }; max_upload_slots_per_torrent = lib.mkOption { type = lib.types.int; description = "Maximum Upload Slots Per Torrent"; default = 4; }; max_upload_speed = lib.mkOption { type = lib.types.int; description = "Maximum Upload Speed"; default = 200; }; max_upload_speed_per_torrent = lib.mkOption { type = lib.types.int; description = "Maximum Upload Speed Per Torrent"; default = 50; }; dont_count_slow_torrents = lib.mkOption { type = lib.types.bool; description = "Do not count slow torrents towards any limits."; default = true; }; }; }; }; extraServiceConfig = lib.mkOption { type = lib.types.attrsOf lib.types.str; description = "Extra configuration given to the systemd service file."; default = { }; example = lib.literalExpression '' { MemoryHigh = "512M"; MemoryMax = "900M"; } ''; }; authEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "OIDC endpoint for SSO"; default = null; example = "https://authelia.example.com"; }; extraUsers = lib.mkOption { description = "Users having access to this deluge instance. Attrset of username to user options."; type = lib.types.attrsOf ( lib.types.submodule { options = { password = lib.mkOption { type = shb.secretFileType; description = "File containing the user password."; }; }; } ); }; localclientPassword = lib.mkOption { description = "Password for mandatory localclient user."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "deluge"; restartUnits = [ "deluged.service" ]; }; }; }; prometheusScraperPassword = lib.mkOption { description = "Password for prometheus scraper. Setting this option will activate the prometheus deluge exporter."; type = lib.types.nullOr ( lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "deluge"; restartUnits = [ "deluged.service" "prometheus.service" ]; }; } ); default = null; }; enabledPlugins = lib.mkOption { type = lib.types.listOf lib.types.str; description = '' Plugins to enable, can include those from additionalPlugins. Label is automatically enabled if any of the `shb.arr.*` service is enabled. ''; example = [ "Label" ]; default = [ ]; }; additionalPlugins = lib.mkOption { type = lib.types.listOf lib.types.path; description = "Location of additional plugins. Each item in the list must be the path to the directory containing the plugin .egg file."; default = [ ]; example = lib.literalExpression '' additionalPlugins = [ (pkgs.callPackage ({ python3, fetchFromGitHub }: python3.pkgs.buildPythonPackage { name = "deluge-autotracker"; version = "1.0.0"; src = fetchFromGitHub { owner = "ibizaman"; repo = "deluge-autotracker"; rev = "cc40d816a497bbf1c2ebeb3d8b1176210548a3e6"; sha256 = "sha256-0LpVdv1fak2a5eX4unjhUcN7nMAl9fgpr3X+7XnQE6c="; } + "/autotracker"; doCheck = false; format = "other"; nativeBuildInputs = [ python3.pkgs.setuptools ]; buildPhase = ''' mkdir "$out" python3 setup.py install --install-lib "$out" '''; doInstallPhase = false; }) {}) ]; ''; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "deluge"; sourceDirectories = [ cfg.dataDir ]; }; }; }; logLevel = lib.mkOption { type = lib.types.nullOr ( lib.types.enum [ "critical" "error" "warning" "info" "debug" ] ); description = "Enable logging."; default = null; example = "info"; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${fqdn}"; externalUrlText = "https://\${config.shb.deluge.subdomain}.\${config.shb.deluge.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.webPort}"; }; }; }; }; config = lib.mkIf cfg.enable ( lib.mkMerge [ { services.deluge = { enable = true; declarative = true; openFirewall = true; inherit (cfg) dataDir; config = { download_location = cfg.settings.downloadLocation; allow_remote = true; daemon_port = cfg.daemonPort; listen_ports = cfg.daemonListenPorts; proxy = lib.optionalAttrs (cfg.proxyPort != null) { force_proxy = true; hostname = "127.0.0.1"; port = cfg.proxyPort; proxy_hostnames = true; proxy_peer_connections = true; proxy_tracker_connections = true; type = 4; # HTTP }; outgoing_interface = cfg.outgoingInterface; enabled_plugins = cfg.enabledPlugins ++ lib.optional (lib.any (x: x.enable) [ config.services.radarr config.services.sonarr config.services.bazarr config.services.readarr config.services.lidarr ]) "Label"; inherit (cfg.settings) max_active_limit max_active_downloading max_active_seeding max_connections_global max_connections_per_torrent max_download_speed max_download_speed_per_torrent max_upload_slots_global max_upload_slots_per_torrent max_upload_speed max_upload_speed_per_torrent dont_count_slow_torrents ; new_release_check = false; }; authFile = "${cfg.dataDir}/.config/deluge/authTemplate"; web.enable = true; web.port = cfg.webPort; }; systemd.services.deluged.preStart = lib.mkBefore ( shb.replaceSecrets { userConfig = cfg.extraUsers // { localclient.password.source = config.shb.deluge.localclientPassword.result.path; } // (lib.optionalAttrs (config.shb.deluge.prometheusScraperPassword != null) { prometheus_scraper.password.source = config.shb.deluge.prometheusScraperPassword.result.path; }); resultPath = "${cfg.dataDir}/.config/deluge/authTemplate"; generator = name: value: pkgs.writeText "delugeAuth" (authGenerator value); } ); systemd.services.deluged.serviceConfig.ExecStart = lib.mkForce ( lib.concatStringsSep " \\\n " ( [ "${config.services.deluge.package}/bin/deluged" "--do-not-daemonize" "--config ${cfg.dataDir}/.config/deluge" ] ++ (lib.optional (!(isNull cfg.logLevel)) "-L ${cfg.logLevel}") ) ); systemd.tmpfiles.rules = let plugins = pkgs.symlinkJoin { name = "deluge-plugins"; paths = cfg.additionalPlugins; }; in [ "L+ ${cfg.dataDir}/.config/deluge/plugins - - - - ${plugins}" ]; shb.nginx.vhosts = [ ( { inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString config.services.deluge.web.port}"; autheliaRules = lib.mkIf (cfg.authEndpoint != null) [ { domain = fqdn; policy = "bypass"; resources = [ "^/json" ]; } { domain = fqdn; policy = "two_factor"; subject = [ "group:deluge_user" ]; } ]; } // (lib.optionalAttrs (cfg.authEndpoint != null) { inherit (cfg) authEndpoint; }) ) ]; } { systemd.services.deluged.serviceConfig = cfg.extraServiceConfig; } (lib.mkIf (config.shb.deluge.prometheusScraperPassword != null) { services.prometheus.exporters.deluge = { enable = true; delugeHost = "127.0.0.1"; delugePort = config.services.deluge.config.daemon_port; delugeUser = "prometheus_scraper"; delugePasswordFile = config.shb.deluge.prometheusScraperPassword.result.path; exportPerTorrentMetrics = true; }; services.prometheus.scrapeConfigs = [ { job_name = "deluge"; static_configs = [ { targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.deluge.port}" ]; labels = { "hostname" = config.networking.hostName; "domain" = cfg.domain; }; } ]; } ]; }) (lib.mkIf (cfg.enable && cfg.enableDashboard) { shb.monitoring.dashboards = [ ./deluge/dashboard/Torrents.json ]; }) ] ); } ================================================ FILE: modules/services/firefly-iii/docs/default.md ================================================ # Firefly-iii Service {#services-firefly-iii} Defined in [`/modules/services/firefly-iii.nix`](@REPO@/modules/services/firefly-iii.nix). This NixOS module is a service that sets up a [Firefly-iii](https://www.firefly-iii.org/) instance. Compared to the stock module from nixpkgs, this one sets up, in a fully declarative manner, LDAP and SSO integration and has a nicer option for secrets. It also sets up the Firefly-iii data importer service and nearly automatically links it to the Firefly-iii instance using a Personal Account Token. Instructions on how to do so is given in the next section. ## Features {#services-firefly-iii-features} - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-firefly-iii-usage-applicationdashboard) ## Usage {#services-firefly-iii-usage} ### Initial Configuration {#services-firefly-iii-usage-configuration} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ```nix shb.firefly-iii = { enable = true; debug = false; appKey.result = config.shb.sops.secret."firefly-iii/appKey".result; dbPassword.result = config.shb.sops.secret."firefly-iii/dbPassword".result; domain = "example.com"; subdomain = "firefly-iii"; siteOwnerEmail = "mail@example.com"; ssl = config.shb.certs.certs.letsencrypt.${domain}; smtp = { host = "smtp.eu.mailgun.org"; port = 587; username = "postmaster@mg.example.com"; from_address = "firefly-iii@example.com"; password.result = config.shb.sops.secret."firefly-iii/smtpPassword".result; }; sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; importer = { # See note hereunder. # firefly-iii-accessToken.result = config.shb.sops.secret."firefly-iii/importerAccessToken".result; }; }; shb.sops.secret."firefly-iii/appKey".request = config.shb.firefly-iii.appKey.request; shb.sops.secret."firefly-iii/dbPassword".request = config.shb.firefly-iii.dbPassword.request; shb.sops.secret."firefly-iii/smtpPassword".request = config.shb.firefly-iii.smtp.password.request; # See not hereunder. # shb.sops.secret."firefly-iii/importerAccessToken".request = config.shb.firefly-iii.importer.firefly-iii-accessToken.request; ``` Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. Note that for `appKey`, the secret length must be exactly 32 characters. The [user](#services-firefly-iii-options-shb.firefly-iii.ldap.userGroup) and [admin](#services-firefly-iii-options-shb.firefly-iii.ldap.adminGroup) LDAP groups are created automatically. Only admin users have access to the Firefly-iii data importer. On the Firefly-iii web UI, the first user to login will be the admin. We cannot yet create multiple admins in the Firefly-iii web UI. On first start, leave the `shb.firefly-iii.importer.firefly-iii-accessToken` option empty. To fill it out and connect the data importer to the Firefly-iii instance, you must first create a personal access token then fill that option and redeploy. ### Backup {#services-firefly-iii-usage-backup} Backing up Firefly-iii using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."firefly-iii" = { request = config.shb.firefly-iii.backup; settings = { enable = true; }; }; ``` The name `"firefly-iii"` in the `instances` can be anything. The `config.shb.firefly-iii.backup` option provides what directories to backup. You can define any number of Restic instances to backup Firefly-iii multiple times. You will then need to configure more options like the `repository`, as explained in the [restic](blocks-restic.html) documentation. ### Certificates {#services-firefly-iii-certs} For Let's Encrypt certificates, add: ```nix { shb.certs.certs.letsencrypt.${domain}.extraDomains = [ "${config.shb.firefly-iii.subdomain}.${config.shb.firefly-iii.domain}" "${config.shb.firefly-iii.importer.subdomain}.${config.shb.firefly-iii.domain}" ]; } ``` ### Impermanence {#services-firefly-iii-impermanence} To save the data folder in an impermanence setup, add: ```nix { shb.zfs.datasets."safe/firefly-iii".path = config.shb.firefly-iii.impermanence; } ``` ### Declarative LDAP {#services-firefly-iii-declarative-ldap} To add a user `USERNAME` to the user and admin groups for Firefly-iii, add: ```nix shb.lldap.ensureUsers.USERNAME.groups = [ config.shb.firefly-iii.ldap.userGroup config.shb.firefly-iii.ldap.adminGroup ]; ``` ### Application Dashboard {#services-firefly-iii-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-firefly-iii-options-shb.firefly-iii.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Finance.services.Firefly-iii = { sortOrder = 1; dashboard.request = config.shb.firefly-iii.dashboard.request; settings.widget.type = "firefly"; }; } ``` The widget type needs to be set manually otherwise it is not displayed correctly. An API key can be set to show extra info: ```nix { shb.homepage.servicesGroups.Finance.services.Firefly-iii = { apiKey.result = config.shb.sops.secret."firefly-iii/homepageApiKey".result; }; shb.sops.secret."firefly-iii/homepageApiKey".request = config.shb.homepage.servicesGroups.Finance.services.Firefly-iii.apiKey.request; } ``` ## Database Inspection {#services-firefly-iii-database-inspection} Access the database with: ```nix sudo -u firefly-iii psql ``` Dump the database with: ```nix sudo -u firefly-iii pg_dump --data-only --inserts firefly-iii > dump ``` ## Mobile Apps {#services-firefly-iii-mobile} This module was tested with the [Abacus iOS](https://github.com/victorbalssa/abacus) mobile app using a Personal Account Token. ## Options Reference {#services-firefly-iii-options} ```{=include=} options id-prefix: services-firefly-iii-options- list-id: selfhostblocks-service-firefly-iii-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/firefly-iii.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.firefly-iii; in { imports = [ ../blocks/nginx.nix ../blocks/lldap.nix ../../lib/module.nix ]; options.shb.firefly-iii = { enable = lib.mkEnableOption "SHB's firefly-iii module"; subdomain = lib.mkOption { type = lib.types.str; description = '' Subdomain under which firefly-iii will be served. ``` . ``` ''; example = "firefly-iii"; }; domain = lib.mkOption { description = '' Domain under which firefly-iii is served. ``` .[:] ``` ''; type = lib.types.str; example = "domain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; siteOwnerEmail = lib.mkOption { description = "Email of the site owner."; type = lib.types.str; example = "mail@example.com"; }; impermanence = lib.mkOption { description = '' Path to save when using impermanence setup. ''; type = lib.types.str; default = config.services.firefly-iii.dataDir; defaultText = "services.firefly-iii.dataDir"; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = config.services.firefly-iii.user; userText = "services.firefly-iii.user"; sourceDirectories = [ config.services.firefly-iii.dataDir ]; sourceDirectoriesText = '' [ config.services.firefly-iii.dataDir ] ''; }; }; }; appKey = lib.mkOption { description = "Encryption key used for sessions. Must be 32 characters long exactly."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.services.firefly-iii.user; ownerText = "services.firefly-iii.user"; restartUnits = [ "firefly-iii-setup.service" ]; }; }; }; dbPassword = lib.mkOption { description = "DB password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = config.services.firefly-iii.user; ownerText = "services.firefly-iii.user"; group = "postgres"; restartUnits = [ "postgresql.service" "firefly-iii-setup.service" ]; }; }; }; ldap = lib.mkOption { description = '' LDAP Integration ''; default = { }; type = lib.types.submodule { options = { userGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to login to Firefly-iii."; default = "firefly-iii_user"; }; adminGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to import data user the Firefly-iii data importer."; default = "firefly-iii_admin"; }; }; }; }; sso = lib.mkOption { description = '' SSO Integration ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "SSO integration."; authEndpoint = lib.mkOption { type = lib.types.str; description = "OIDC endpoint for SSO."; example = "https://authelia.example.com"; }; port = lib.mkOption { description = "If given, adds a port to the endpoint."; type = lib.types.nullOr lib.types.port; default = null; }; provider = lib.mkOption { type = lib.types.enum [ "Authelia" ]; description = "OIDC provider name, used for display."; default = "Authelia"; }; clientID = lib.mkOption { type = lib.types.str; description = "Client ID for the OIDC endpoint."; default = "firefly-iii"; }; authorization_policy = lib.mkOption { type = lib.types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; adminGroup = lib.mkOption { type = lib.types.str; description = "Group admins must belong to to be able to login to Firefly-iii."; default = "firefly-iii_admin"; }; secret = lib.mkOption { description = "OIDC shared secret."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "firefly-iii"; restartUnits = [ "firefly-iii-setup.service" ]; }; }; }; secretForAuthelia = lib.mkOption { description = "OIDC shared secret. Content must be the same as `secretFile` option."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "authelia"; }; }; }; }; }; }; smtp = lib.mkOption { description = '' If set, send notifications through smtp. https://docs.firefly-iii.org/how-to/firefly-iii/advanced/notifications/ ''; default = null; type = lib.types.nullOr ( lib.types.submodule { options = { from_address = lib.mkOption { type = lib.types.str; description = "SMTP address from which the emails originate."; example = "authelia@mydomain.com"; }; host = lib.mkOption { type = lib.types.str; description = "SMTP host to send the emails to."; }; port = lib.mkOption { type = lib.types.port; description = "SMTP port to send the emails to."; default = 25; }; username = lib.mkOption { type = lib.types.str; description = "Username to connect to the SMTP host."; }; password = lib.mkOption { description = "File containing the password to connect to the SMTP host."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.services.firefly-iii.user; ownerText = "services.firefly-iii.user"; restartUnits = [ "firefly-iii-setup.service" ]; }; }; }; }; } ); }; debug = lib.mkOption { type = lib.types.bool; description = "Enable more verbose logging."; default = false; example = true; }; importer = lib.mkOption { description = '' Configuration for Firefly-iii data importer. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "Firefly-iii Data Importer." // { default = true; }; subdomain = lib.mkOption { type = lib.types.str; description = '' Subdomain under which the firefly-iii data importer will be served. ''; default = "${cfg.subdomain}-importer"; defaultText = lib.literalExpression "\${shb.firefly-iii.subdomain}-importer"; }; firefly-iii-accessToken = lib.mkOption { type = lib.types.nullOr ( lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.services.firefly-iii-data-importer.user; ownerText = "services.firefly-iii-data-importer.user"; restartUnits = [ "firefly-iii-data-importer-setup.service" ]; }; } ); description = '' Create a Personal Access Token then set then token in this option. ''; default = null; }; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.firefly-iii.subdomain}.\${config.shb.firefly-iii.domain}"; # This works thanks to the Personal Access Token. internalUrl = "https://${cfg.subdomain}.${cfg.domain}"; internalUrlText = "https://\${config.shb.firefly-iii.subdomain}.\${config.shb.firefly-iii.domain}"; }; }; }; }; config = lib.mkIf cfg.enable ( lib.mkMerge [ { services.firefly-iii = { enable = true; group = "nginx"; virtualHost = "${cfg.subdomain}.${cfg.domain}"; # https://github.com/firefly-iii/firefly-iii/blob/main/.env.example settings = { APP_ENV = "production"; APP_URL = "https://${cfg.subdomain}.${cfg.domain}"; APP_DEBUG = cfg.debug; APP_LOG_LEVEL = if cfg.debug then "debug" else "notice"; LOG_CHANNEL = "stdout"; APP_KEY_FILE = cfg.appKey.result.path; SITE_OWNER = cfg.siteOwnerEmail; DB_CONNECTION = "pgsql"; DB_HOST = "localhost"; DB_PORT = config.services.postgresql.settings.port; DB_DATABASE = "firefly-iii"; DB_USERNAME = "firefly-iii"; DB_PASSWORD_FILE = cfg.dbPassword.result.path; # MAP_DEFAULT_LAT = "51.983333"; # MAP_DEFAULT_LONG = "5.916667"; # MAP_DEFAULT_ZOOM = "6"; }; }; shb.postgresql.enableTCPIP = true; shb.postgresql.ensures = [ { username = "firefly-iii"; database = "firefly-iii"; passwordFile = cfg.dbPassword.result.path; } ]; # This should be using a contract instead of setting the option directly. shb.lldap = lib.mkIf config.shb.lldap.enable { ensureGroups = { ${cfg.ldap.userGroup} = { }; ${cfg.ldap.adminGroup} = { }; }; }; # We enable the firefly-iii nginx integration and merge it with SHB's nginx configuration. services.firefly-iii.enableNginx = true; shb.nginx.vhosts = [ { inherit (cfg) subdomain domain ssl; } ]; } (lib.mkIf cfg.importer.enable { services.firefly-iii-data-importer = { enable = true; virtualHost = "${cfg.importer.subdomain}.${cfg.domain}"; settings = { FIREFLY_III_URL = "https://${config.services.firefly-iii.virtualHost}"; } // lib.optionalAttrs (cfg.importer.firefly-iii-accessToken != null) { FIREFLY_III_ACCESS_TOKEN_FILE = cfg.importer.firefly-iii-accessToken.result.path; }; }; # We enable the firefly-iii-data-importer nginx integration and merge it with SHB's nginx configuration. services.firefly-iii-data-importer.enableNginx = true; shb.nginx.vhosts = [ { inherit (cfg) domain ssl; subdomain = cfg.importer.subdomain; } ]; }) (lib.mkIf (cfg.smtp != null) { services.firefly-iii.settings = { MAIL_MAILER = "smtp"; MAIL_HOST = cfg.smtp.host; MAIL_PORT = cfg.smtp.port; MAIL_FROM = cfg.smtp.from_address; MAIL_USERNAME = cfg.smtp.username; MAIL_PASSWORD_FILE = cfg.smtp.password.result.path; MAIL_ENCRYPTION = "tls"; }; }) (lib.mkIf cfg.sso.enable { services.firefly-iii.settings = { AUTHENTICATION_GUARD = "remote_user_guard"; AUTHENTICATION_GUARD_HEADER = "HTTP_X_FORWARDED_USER"; }; shb.nginx.vhosts = [ { inherit (cfg) subdomain domain ssl; inherit (cfg.sso) authEndpoint; phpForwardAuth = true; autheliaRules = [ { domain = "${cfg.subdomain}.${cfg.domain}"; policy = "bypass"; resources = [ "^/api" ]; } { domain = "${cfg.subdomain}.${cfg.domain}"; policy = cfg.sso.authorization_policy; subject = [ "group:${cfg.ldap.userGroup}" "group:${cfg.ldap.adminGroup}" ]; } ]; } ]; }) (lib.mkIf (cfg.sso.enable && cfg.importer.enable) { shb.nginx.vhosts = [ { inherit (cfg.importer) subdomain; inherit (cfg) domain ssl; inherit (cfg.sso) authEndpoint; autheliaRules = [ { domain = "${cfg.importer.subdomain}.${cfg.domain}"; policy = cfg.sso.authorization_policy; subject = [ "group:${cfg.ldap.adminGroup}" ]; } ]; } ]; }) ] ); } ================================================ FILE: modules/services/forgejo/docs/default.md ================================================ # Forgejo Service {#services-forgejo} Defined in [`/modules/services/forgejo.nix`](@REPO@/modules/services/forgejo.nix). This NixOS module is a service that sets up a [Forgejo](https://forgejo.org/) instance. Compared to the stock module from nixpkgs, this one sets up, in a fully declarative manner, LDAP and SSO integration as well as one local runner. ## Features {#services-forgejo-features} - Declarative creation of users, admin or not. - Also declarative [LDAP](#services-forgejo-options-shb.forgejo.ldap) Configuration. [Manual](#services-forgejo-usage-ldap). - Declarative [SSO](#services-forgejo-options-shb.forgejo.sso) Configuration. [Manual](#services-forgejo-usage-sso). - Declarative [local runner](#services-forgejo-options-shb.forgejo.localActionRunner) Configuration. - Access through [subdomain](#services-forgejo-options-shb.forgejo.subdomain) using reverse proxy. [Manual](#services-forgejo-usage-configuration). - Access through [HTTPS](#services-forgejo-options-shb.forgejo.ssl) using reverse proxy. [Manual](#services-forgejo-usage-configuration). - [Backup](#services-forgejo-options-shb.forgejo.sso) through the [backup block](./blocks-backup.html). [Manual](#services-forgejo-usage-backup). - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-forgejo-usage-applicationdashboard) ## Usage {#services-forgejo-usage} ### Initial Configuration {#services-forgejo-usage-configuration} The following snippet enables Forgejo and makes it available under the `forgejo.example.com` endpoint. ```nix shb.forgejo = { enable = true; subdomain = "forgejo"; domain = "example.com"; users = { "theadmin" = { isAdmin = true; email = "theadmin@example.com"; password.result = config.shb.sops.secret.forgejoAdminPassword.result; }; "theuser" = { email = "theuser@example.com"; password.result = config.shb.sops.secret.forgejoUserPassword.result; }; }; }; shb.sops.secret."forgejo/admin/password" = { request = config.shb.forgejo.users."theadmin".password.request; }; shb.sops.secret."forgejo/user/password" = { request = config.shb.forgejo.users."theuser".password.request; }; ``` Two users are created, `theadmin` and `theuser`, respectively with the passwords `forgejo/admin/password` and `forgejo/user/password` from a SOPS file. This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. ### Forgejo through HTTPS {#services-forgejo-usage-https} :::: {.note} We will build upon the [Initial Configuration](#services-forgejo-usage-configuration) section, so please follow that first. :::: If the `shb.ssl` block is used (see [manual](blocks-ssl.html#usage) on how to set it up), the instance will be reachable at `https://forgejo.example.com`. Here is an example with Let's Encrypt certificates, validated using the HTTP method: ```nix shb.certs.certs.letsencrypt."example.com" = { domain = "example.com"; group = "nginx"; reloadServices = [ "nginx.service" ]; adminEmail = "myemail@mydomain.com"; }; ``` Then you can tell Forgejo to use those certificates. ```nix shb.certs.certs.letsencrypt."example.com".extraDomains = [ "forgejo.example.com" ]; shb.forgejo = { ssl = config.shb.certs.certs.letsencrypt."example.com"; }; ``` ### With LDAP Support {#services-forgejo-usage-ldap} :::: {.note} We will build upon the [HTTPS](#services-forgejo-usage-https) section, so please follow that first. :::: We will use the [LLDAP block][] provided by Self Host Blocks. Assuming it [has been set already][LLDAP block setup], add the following configuration: [LLDAP block]: blocks-lldap.html [LLDAP block setup]: blocks-lldap.html#blocks-lldap-global-setup ```nix shb.forgejo.ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminPassword.result = config.shb.sops.secret."forgejo/ldap/adminPassword".result }; shb.sops.secret."forgejo/ldap/adminPassword" = { request = config.shb.forgejo.ldap.adminPassword.request; settings.key = "ldap/userPassword"; }; ``` The `shb.forgejo.ldap.adminPasswordFile` must be the same as the `shb.lldap.ldapUserPasswordFile` which is achieved with the `key` option. The other secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. And that's it. Now, go to the LDAP server at `http://ldap.example.com`, create the `forgejo_user` and `forgejo_admin` groups, create a user and add it to one or both groups. When that's done, go back to the Forgejo server at `http://forgejo.example.com` and login with that user. ### With SSO Support {#services-forgejo-usage-sso} :::: {.note} We will build upon the [LDAP](#services-forgejo-usage-ldap) section, so please follow that first. :::: We will use the [SSO block][] provided by Self Host Blocks. Assuming it [has been set already][SSO block setup], add the following configuration: [SSO block]: blocks-sso.html [SSO block setup]: blocks-sso.html#blocks-sso-global-setup ```nix shb.forgejo.sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secretFile = ; secretFileForAuthelia = ; }; ``` Passing the `ssl` option will auto-configure nginx to force SSL connections with the given certificate. The `shb.forgejo.sso.secretFile` and `shb.forgejo.sso.secretFileForAuthelia` options must have the same content. The former is a file that must be owned by the `forgejo` user while the latter must be owned by the `authelia` user. I want to avoid needing to define the same secret twice with a future secrets SHB block. ### SMTP {#services-forgejo-usage-smtp} To send e-mails, notifications, define the SMTP settings like so: ```nix { services.forgejo = { smtp = { host = "smtp.mailgun.org"; port = 587; username = "postmaster@mg.${domain}"; from_address = "authelia@${domain}"; password.result = config.shb.sops.secret."forgejo/smtpPassword".result; }; }; shb.sops.secret."forgejo/smtpPassword" = { request = config.shb.forgejo.smtp.password.request; }; } ``` ### Backup {#services-forgejo-usage-backup} Every hour, Forgejo takes a backup using the [built-in `dump` command](https://forgejo.org/docs/latest/admin/command-line/#dump). This backup is ephemeral and should be moved in a permanent location. This can be accomplished using the following config. Backing up Forgejo using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."forgejo" = { request = config.shb.forgejo.backup; settings = { enable = true; }; }; ``` The name `"forgejo"` in the `instances` can be anything. The `config.shb.forgejo.backup` option provides what directories to backup. You can define any number of Restic instances to backup Forgejo multiple times. ### Application Dashboard {#services-forgejo-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-forgejo-options-shb.forgejo.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Admin.services.Forgejo = { sortOrder = 1; dashboard.request = config.shb.forgejo.dashboard.request; }; } ``` ### Extra Settings {#services-forgejo-usage-extra-settings} Other Forgejo settings can be accessed through the nixpkgs [stock service][]. [stock service]: https://search.nixos.org/options?channel=24.05&from=0&size=50&sort=alpha_asc&type=packages&query=services.forgejo ## Debug {#services-forgejo-debug} In case of an issue, check the logs for systemd service `forgejo.service`. Enable verbose logging by setting the `shb.forgejo.debug` boolean to `true`. Access the database with `sudo -u forgejo psql`. ## Options Reference {#services-forgejo-options} ```{=include=} options id-prefix: services-forgejo-options- list-id: selfhostblocks-service-forgejo-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/forgejo.nix ================================================ { config, options, pkgs, lib, shb, ... }: let cfg = config.shb.forgejo; inherit (lib) all attrNames concatMapStringsSep getExe lists literalExpression mapAttrsToList mkBefore mkEnableOption mkForce mkIf mkMerge mkOption mkOverride nameValuePair optionalString optionals ; inherit (lib.types) attrsOf bool enum listOf nullOr package port submodule str ; in { imports = [ ../blocks/nginx.nix ../blocks/lldap.nix (lib.mkRemovedOptionModule [ "shb" "forgejo" "adminPassword" ] '' Instead, define an admin user in shb.forgejo.users and give it the same password, like so: shb.forgejo.users = { "forgejoadmin" = { isAdmin = true; email = "forgejoadmin@example.com"; password.result = ; }; }; '') ]; options.shb.forgejo = { enable = mkEnableOption "selfhostblocks.forgejo"; subdomain = mkOption { type = str; description = '' Subdomain under which Forgejo will be served. ``` .[:] ``` ''; example = "forgejo"; }; domain = mkOption { description = '' Domain under which Forgejo is served. ``` .[:] ``` ''; type = str; example = "domain.com"; }; ssl = mkOption { description = "Path to SSL files"; type = nullOr shb.contracts.ssl.certs; default = null; }; ldap = mkOption { description = '' LDAP Integration. ''; default = { }; type = nullOr (submodule { options = { enable = mkEnableOption "LDAP integration."; provider = mkOption { type = enum [ "LLDAP" ]; description = "LDAP provider name, used for display."; default = "LLDAP"; }; host = mkOption { type = str; description = '' Host serving the LDAP server. ''; default = "127.0.0.1"; }; port = mkOption { type = port; description = '' Port of the service serving the LDAP server. ''; default = 389; }; dcdomain = mkOption { type = str; description = "dc domain for ldap."; example = "dc=mydomain,dc=com"; }; adminName = mkOption { type = str; description = "Admin user of the LDAP server. Cannot be reserved word 'admin'."; default = "admin"; }; adminPassword = mkOption { description = "LDAP admin password."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; userGroup = mkOption { type = str; description = "Group users must belong to be able to login."; default = "forgejo_user"; }; adminGroup = mkOption { type = str; description = "Group users must belong to be admins."; default = "forgejo_admin"; }; waitForSystemdServices = mkOption { type = listOf str; default = [ ]; description = '' List of systemd services to wait on before starting. This is needed because forgejo will try a lookup on the LDAP instance and will abort setting up LDAP if it can't reach it. ''; }; }; }); }; sso = mkOption { description = '' Setup SSO integration. ''; default = { }; type = submodule { options = { enable = mkEnableOption "SSO integration."; provider = mkOption { type = enum [ "Authelia" ]; description = "OIDC provider name, used for display."; default = "Authelia"; }; endpoint = mkOption { type = str; description = "OIDC endpoint for SSO."; example = "https://authelia.example.com"; }; clientID = mkOption { type = str; description = "Client ID for the OIDC endpoint."; default = "forgejo"; }; authorization_policy = mkOption { type = enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; sharedSecret = mkOption { description = "OIDC shared secret for Forgejo."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; sharedSecretForAuthelia = mkOption { description = "OIDC shared secret for Authelia."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "authelia"; }; }; }; }; }; }; users = mkOption { description = "Users managed declaratively."; default = { }; type = attrsOf (submodule { options = { isAdmin = mkOption { description = "Set user as admin or not."; type = bool; default = false; }; email = mkOption { description = '' Email of user. This is only set when the user is created, changing this later on will have no effect. ''; type = str; }; password = mkOption { description = "Forgejo admin user password."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; }; }); }; databasePassword = mkOption { description = "File containing the Forgejo database password."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; repositoryRoot = mkOption { type = nullOr str; description = "Path where to store the repositories. If null, uses the default under the Forgejo StateDir."; default = null; example = "/srv/forgejo"; }; localActionRunner = mkOption { type = bool; default = true; description = '' Enable local action runner that runs for all labels. ''; }; hostPackages = mkOption { type = listOf package; default = with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ]; defaultText = literalExpression '' with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ] ''; description = '' List of packages, that are available to actions, when the runner is configured with a host execution label. ''; }; backup = mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = options.services.forgejo.user.value; sourceDirectories = [ config.services.forgejo.dump.backupDir ] ++ optionals (cfg.repositoryRoot != null) [ cfg.repositoryRoot ]; }; }; }; mount = mkOption { type = shb.contracts.mount; description = '' Mount configuration. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."forgejo" = { poolName = "root"; } // config.shb.forgejo.mount; ``` ''; readOnly = true; default = { path = config.services.forgejo.stateDir; }; }; smtp = mkOption { description = '' Send notifications by smtp. ''; default = null; type = nullOr (submodule { options = { from_address = mkOption { type = str; description = "SMTP address from which the emails originate."; example = "authelia@mydomain.com"; }; host = mkOption { type = str; description = "SMTP host to send the emails to."; }; port = mkOption { type = port; description = "SMTP port to send the emails to."; default = 25; }; username = mkOption { type = str; description = "Username to connect to the SMTP host."; }; password = mkOption { description = "File containing the password to connect to the SMTP host."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "forgejo"; group = "forgejo"; restartUnits = [ "forgejo.service" ]; }; }; }; }; }); }; debug = mkOption { description = "Enable debug logging."; type = bool; default = false; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.forgejo.subdomain}.\${config.shb.forgejo.domain}"; internalUrl = "https://${cfg.subdomain}.${cfg.domain}"; internalUrlText = "https://\${config.shb.forgejo.subdomain}.\${config.shb.forgejo.domain}"; }; }; }; }; config = mkMerge [ (mkIf cfg.enable { services.forgejo = { enable = true; repositoryRoot = mkIf (cfg.repositoryRoot != null) cfg.repositoryRoot; settings = { server = { DOMAIN = cfg.domain; PROTOCOL = "http+unix"; ROOT_URL = "https://${cfg.subdomain}.${cfg.domain}/"; }; service.DISABLE_REGISTRATION = true; log.LEVEL = if cfg.debug then "Debug" else "Info"; cron = { ENABLE = true; RUN_AT_START = true; SCHEDULE = "@every 1h"; }; }; }; # 1 lower than default, to solve conflict between shb.postgresql and nixpkgs' forgejo module. services.postgresql.enable = mkOverride 999 true; # https://github.com/NixOS/nixpkgs/issues/258371#issuecomment-2271967113 systemd.services.forgejo.serviceConfig.Type = mkForce "exec"; shb.nginx.vhosts = [ { inherit (cfg) domain subdomain ssl; upstream = "http://unix:${config.services.forgejo.settings.server.HTTP_ADDR}"; } ]; }) (mkIf cfg.enable { services.forgejo.database = { type = "postgres"; passwordFile = cfg.databasePassword.result.path; }; }) (mkIf cfg.enable { services.forgejo.dump = { enable = true; type = "tar.gz"; interval = "hourly"; }; systemd.services.forgejo-dump.preStart = "rm -f ${config.services.forgejo.dump.backupDir}/*.tar.gz"; }) # For Forgejo setup: https://github.com/lldap/lldap/blob/main/example_configs/gitea.md # For cli info: https://docs.gitea.com/usage/command-line # Security protocols in: https://codeberg.org/forgejo/forgejo/src/branch/forgejo/services/auth/source/ldap/security_protocol.go#L27-L31 (mkIf (cfg.enable && cfg.ldap.enable != false) { systemd.services.forgejo.wants = cfg.ldap.waitForSystemdServices; systemd.services.forgejo.after = cfg.ldap.waitForSystemdServices; shb.lldap.ensureGroups = { ${cfg.ldap.adminGroup} = { }; ${cfg.ldap.userGroup} = { }; }; # The delimiter in the `cut` command is a TAB! systemd.services.forgejo.preStart = let provider = "SHB-${cfg.ldap.provider}"; in '' auth="${getExe config.services.forgejo.package} admin auth" echo "Trying to find existing ldap configuration for ${provider}"... set +e -o pipefail id="$($auth list | grep "${provider}.*LDAP" | cut -d' ' -f1)" found=$? set -e +o pipefail if [[ $found = 0 ]]; then echo Found ldap configuration at id=$id, updating it if needed. $auth update-ldap \ --id $id \ --name ${provider} \ --host ${cfg.ldap.host} \ --port ${toString cfg.ldap.port} \ --bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \ --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --security-protocol Unencrypted \ --user-search-base ou=people,${cfg.ldap.dcdomain} \ --user-filter '(&(|(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain}))(|(uid=%[1]s)(mail=%[1]s)))' \ --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ --username-attribute uid \ --firstname-attribute givenName \ --surname-attribute sn \ --email-attribute mail \ --avatar-attribute jpegPhoto \ --synchronize-users echo "Done updating LDAP configuration." else echo Did not find any ldap configuration, creating one with name ${provider}. $auth add-ldap \ --name ${provider} \ --host ${cfg.ldap.host} \ --port ${toString cfg.ldap.port} \ --bind-dn uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain} \ --bind-password $(tr -d '\n' < ${cfg.ldap.adminPassword.result.path}) \ --security-protocol Unencrypted \ --user-search-base ou=people,${cfg.ldap.dcdomain} \ --user-filter '(&(|(memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain})(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain}))(|(uid=%[1]s)(mail=%[1]s)))' \ --admin-filter '(memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain})' \ --username-attribute uid \ --firstname-attribute givenName \ --surname-attribute sn \ --email-attribute mail \ --avatar-attribute jpegPhoto \ --synchronize-users echo "Done adding LDAP configuration." fi ''; }) # For Authelia to Forgejo integration: https://www.authelia.com/integration/openid-connect/gitea/ # For Forgejo config: https://forgejo.org/docs/latest/admin/config-cheat-sheet # For cli info: https://docs.gitea.com/usage/command-line (mkIf (cfg.enable && cfg.sso.enable != false) { assertions = [ { assertion = cfg.ldap.enable == true; message = "'shb.forgejo.ldap.enable' must be set to true and ldap configured when 'shb.forgejo.sso.enable' is true. Otherwise you will never be able to register new accounts."; } ]; services.forgejo.settings = { oauth2 = { ENABLED = true; }; openid = { ENABLE_OPENID_SIGNIN = false; ENABLE_OPENID_SIGNUP = true; WHITELISTED_URIS = cfg.sso.endpoint; }; service = { # DISABLE_REGISTRATION = mkForce false; # ALLOW_ONLY_EXTERNAL_REGISTRATION = false; SHOW_REGISTRATION_BUTTON = false; }; }; # The delimiter in the `cut` command is a TAB! systemd.services.forgejo.preStart = let provider = "SHB-${cfg.sso.provider}"; in '' auth="${getExe config.services.forgejo.package} admin auth" echo "Trying to find existing sso configuration for ${provider}"... set +e -o pipefail id="$($auth list | grep "${provider}.*OAuth2" | cut -d' ' -f1)" found=$? set -e +o pipefail if [[ $found = 0 ]]; then echo Found sso configuration at id=$id, updating it if needed. $auth update-oauth \ --id $id \ --name ${provider} \ --provider openidConnect \ --key forgejo \ --secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \ --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration else echo Did not find any sso configuration, creating one with name ${provider}. $auth add-oauth \ --name ${provider} \ --provider openidConnect \ --key forgejo \ --secret $(tr -d '\n' < ${cfg.sso.sharedSecret.result.path}) \ --auto-discover-url ${cfg.sso.endpoint}/.well-known/openid-configuration fi ''; shb.authelia.oidcClients = lists.optionals (!(isNull cfg.sso)) [ ( let provider = "SHB-${cfg.sso.provider}"; in { client_id = cfg.sso.clientID; client_name = "Forgejo"; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; public = false; authorization_policy = cfg.sso.authorization_policy; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/user/oauth2/${provider}/callback" ]; } ) ]; }) (mkIf cfg.enable { assertions = [ { assertion = all (u: u != "admin") (attrNames cfg.users); message = "Username cannot be 'admin'."; } ]; systemd.services.forgejo.preStart = '' admin="${getExe config.services.forgejo.package} admin user" '' + concatMapStringsSep "\n" (u: '' if ! $admin list | grep "${u.name}"; then $admin create ${optionalString u.value.isAdmin "--admin"} --email "${u.value.email}" --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})" else $admin change-password --must-change-password=false --username "${u.name}" --password "$(tr -d '\n' < ${u.value.password.result.path})" fi '') (mapAttrsToList nameValuePair cfg.users); }) (mkIf (cfg.enable && cfg.smtp != null) { services.forgejo.settings.mailer = { ENABLED = true; SMTP_ADDR = "${cfg.smtp.host}:${toString cfg.smtp.port}"; FROM = cfg.smtp.from_address; USER = cfg.smtp.username; PASSWD = cfg.smtp.password.result.path; }; }) # https://wiki.nixos.org/wiki/Forgejo#Runner (mkIf cfg.enable { services.forgejo.settings.actions = { ENABLED = true; DEFAULT_ACTIONS_URL = "github"; }; services.gitea-actions-runner = mkIf cfg.localActionRunner { package = pkgs.forgejo-runner; instances.local = { enable = true; name = "local"; url = let protocol = if cfg.ssl != null then "https" else "http"; in "${protocol}://${cfg.subdomain}.${cfg.domain}"; tokenFile = ""; # Empty variable to satisfy an assertion. labels = [ # "ubuntu-latest:docker://node:16-bullseye" # "ubuntu-22.04:docker://node:16-bullseye" # "ubuntu-20.04:docker://node:16-bullseye" # "ubuntu-18.04:docker://node:16-buster" "native:host" ]; inherit (cfg) hostPackages; }; }; # This combined with the next statement takes care of # automatically registering a forgejo runner. systemd.services.forgejo.postStart = mkIf cfg.localActionRunner (mkBefore '' ${pkgs.bash}/bin/bash -c '(while ! ${pkgs.netcat-openbsd}/bin/nc -z -U ${config.services.forgejo.settings.server.HTTP_ADDR}; do echo "Waiting for unix ${config.services.forgejo.settings.server.HTTP_ADDR} to open..."; sleep 2; done); sleep 2' actions="${getExe config.services.forgejo.package} actions" echo -n TOKEN= > /run/forgejo/forgejo-runner-token $actions generate-runner-token >> /run/forgejo/forgejo-runner-token ''); systemd.services.gitea-runner-local.serviceConfig = { # LoadCredential = "TOKEN_FILE:/run/forgejo/forgejo-runner-token"; # EnvironmentFile = [ "$CREDENTIALS_DIRECTORY/TOKEN_FILE" ]; EnvironmentFile = [ "/run/forgejo/forgejo-runner-token" ]; }; systemd.services.gitea-runner-local.wants = [ "forgejo.service" ]; systemd.services.gitea-runner-local.after = [ "forgejo.service" ]; }) ]; } ================================================ FILE: modules/services/grocy.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.grocy; fqdn = "${cfg.subdomain}.${cfg.domain}"; in { options.shb.grocy = { enable = lib.mkEnableOption "selfhostblocks.grocy"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which grocy will be served."; example = "grocy"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which grocy will be served."; example = "mydomain.com"; }; dataDir = lib.mkOption { description = "Folder where Grocy will store all its data."; type = lib.types.str; default = "/var/lib/grocy"; }; currency = lib.mkOption { type = lib.types.str; description = "ISO 4217 code for the currency to display."; default = "USD"; example = "NOK"; }; culture = lib.mkOption { type = lib.types.enum [ "de" "en" "da" "en_GB" "es" "fr" "hu" "it" "nl" "no" "pl" "pt_BR" "ru" "sk_SK" "sv_SE" "tr" ]; default = "en"; description = '' Display language of the frontend. ''; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; extraServiceConfig = lib.mkOption { type = lib.types.attrsOf lib.types.str; description = "Extra configuration given to the systemd service file."; default = { }; example = lib.literalExpression '' { MemoryHigh = "512M"; MemoryMax = "900M"; } ''; }; backup = lib.mkOption { description = '' Backup configuration. ''; readOnly = true; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "grocy"; sourceDirectories = [ cfg.dataDir ]; }; }; }; logLevel = lib.mkOption { type = lib.types.nullOr ( lib.types.enum [ "critical" "error" "warning" "info" "debug" ] ); description = "Enable logging."; default = false; example = true; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.grocy.subdomain}.\${config.shb.grocy.domain}"; internalUrl = "https://${cfg.subdomain}.${cfg.domain}"; internalUrlText = "https://\${config.shb.grocy.subdomain}.\${config.shb.grocy.domain}"; }; }; }; }; config = lib.mkIf cfg.enable ( lib.mkMerge [ { services.grocy = { enable = true; hostName = fqdn; nginx.enableSSL = !(isNull cfg.ssl); dataDir = cfg.dataDir; settings.currency = cfg.currency; settings.culture = cfg.culture; }; services.phpfpm.pools.grocy.group = lib.mkForce "grocy"; users.groups.grocy = { }; users.users.grocy.group = lib.mkForce "grocy"; services.nginx.virtualHosts."${fqdn}" = { enableACME = lib.mkForce false; sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; }; } { systemd.services.grocyd.serviceConfig = cfg.extraServiceConfig; } ] ); } ================================================ FILE: modules/services/hledger.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.hledger; fqdn = "${cfg.subdomain}.${cfg.domain}"; in { imports = [ ../blocks/nginx.nix ]; options.shb.hledger = { enable = lib.mkEnableOption "selfhostblocks.hledger"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Authelia will be served."; example = "ha"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which Authelia will be served."; example = "mydomain.com"; }; dataDir = lib.mkOption { description = "Folder where Hledger will store all its data."; type = lib.types.str; default = "/var/lib/hledger"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; port = lib.mkOption { type = lib.types.int; description = "HLedger port"; default = 5000; }; localNetworkIPRange = lib.mkOption { type = lib.types.str; description = "Local network range, to restrict access to the UI to only those IPs."; default = null; example = "192.168.1.1/24"; }; authEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "OIDC endpoint for SSO"; example = "https://authelia.example.com"; default = null; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "hledger"; sourceDirectories = [ cfg.dataDir ]; }; }; }; extraArguments = lib.mkOption { description = "Extra arguments append to the hledger command."; default = [ "--forecast" ]; type = lib.types.listOf lib.types.str; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.hledger.subdomain}.\${config.shb.hledger.domain}"; internalUrl = "http://127.0.0.1:${toString config.services.hledger-web.port}"; internalUrlText = "http://127.0.0.1:\${config.services.hledger-web.port}"; }; }; }; }; config = lib.mkIf cfg.enable { services.hledger-web = { enable = true; # Must be empty otherwise it repeats the fqdn, we get something like https://${fqdn}/${fqdn}/ baseUrl = ""; stateDir = cfg.dataDir; journalFiles = [ "hledger.journal" ]; host = "127.0.0.1"; port = cfg.port; allow = "edit"; extraOptions = cfg.extraArguments; }; systemd.services.hledger-web = { # If the hledger.journal file does not exist, hledger-web refuses to start, so we create an # empty one if it does not exist yet.. preStart = '' test -f /var/lib/hledger/hledger.journal || touch /var/lib/hledger/hledger.journal ''; serviceConfig.StateDirectory = "hledger"; }; shb.nginx.vhosts = [ { inherit (cfg) subdomain domain authEndpoint ssl ; upstream = "http://${toString config.services.hledger-web.host}:${toString config.services.hledger-web.port}"; autheliaRules = [ { domain = fqdn; policy = "two_factor"; subject = [ "group:hledger_user" ]; } ]; } ]; }; } ================================================ FILE: modules/services/home-assistant/docs/default.md ================================================ # Home-Assistant Service {#services-home-assistant} Defined in [`/modules/services/home-assistant.nix`](@REPO@/modules/services/home-assistant.nix). This NixOS module is a service that sets up a [Home-Assistant](https://www.home-assistant.io/) instance. Compared to the stock module from nixpkgs, this one sets up, in a fully declarative manner LDAP and SSO integration. ## Features {#services-home-assistant-features} - Declarative creation of users, admin or not. - Also declarative [LDAP](#services-home-assistant-options-shb.home-assistant.ldap) Configuration. [Manual](#services-home-assistant-usage-ldap). - Access through [subdomain](#services-home-assistant-options-shb.home-assistant.subdomain) using reverse proxy. [Manual](#services-home-assistant-usage-configuration). - Access through [HTTPS](#services-home-assistant-options-shb.home-assistant.ssl) using reverse proxy. [Manual](#services-home-assistant-usage-configuration). - [Backup](#services-home-assistant-options-shb.home-assistant.backup) through the [backup block](./blocks-backup.html). [Manual](#services-home-assistant-usage-backup). - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-home-assistant-usage-applicationdashboard) - Not yet: declarative SSO. ## Usage {#services-home-assistant-usage} ### Initial Configuration {#services-home-assistant-usage-configuration} The following snippet enables Home-Assistant and makes it available under the `ha.example.com` endpoint. ```nix shb.home-assistant = { enable = true; subdomain = "ha"; domain = "example.com"; config = { name = "SelfHostBlocks - Home Assistant"; country.source = config.shb.sops.secret."home-assistant/country".result.path; latitude.source = config.shb.sops.secret."home-assistant/latitude_home".result.path; longitude.source = config.shb.sops.secret."home-assistant/longitude_home".result.path; time_zone.source = config.shb.sops.secret."home-assistant/time_zone".result.path; unit_system = "metric"; }; }; shb.sops.secret."home-assistant/country".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; shb.sops.secret."home-assistant/latitude_home".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; shb.sops.secret."home-assistant/longitude_home".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; shb.sops.secret."home-assistant/time_zone".request = { mode = "0440"; owner = "hass"; group = "hass"; restartUnits = [ "home-assistant.service" ]; }; ``` This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. Any item in the `config` can be passed a secret, which means it will not appear in the `/nix/store` and instead be added to the config file out of band, here using sops. To do that, append `.source` to the settings name and give it the path to the secret. I advise using secrets to set personally identifiable information, like shown in the snippet. Especially if you share your repository publicly. ### Home-Assistant through HTTPS {#services-home-assistant-usage-https} :::: {.note} We will build upon the [Initial Configuration](#services-home-assistant-usage-configuration) section, so please follow that first. :::: If the `shb.ssl` block is used (see [manual](blocks-ssl.html#usage) on how to set it up), the instance will be reachable at `https://ha.example.com`. Here is an example with Let's Encrypt certificates, validated using the HTTP method. First, set the global configuration for your domain: ```nix shb.certs.certs.letsencrypt."example.com" = { domain = "example.com"; group = "nginx"; reloadServices = [ "nginx.service" ]; adminEmail = "myemail@mydomain.com"; }; ``` Then you can tell Home-Assistant to use those certificates. ```nix shb.certs.certs.letsencrypt."example.com".extraDomains = [ "ha.example.com" ]; shb.home-assistant = { ssl = config.shb.certs.certs.letsencrypt."example.com"; }; ``` ### With LDAP Support {#services-home-assistant-usage-ldap} :::: {.note} We will build upon the [HTTPS](#services-home-assistant-usage-https) section, so please follow that first. :::: We will use the [LLDAP block][] provided by Self Host Blocks. Assuming it [has been set already][LLDAP block setup], add the following configuration: [LLDAP block]: blocks-lldap.html [LLDAP block setup]: blocks-lldap.html#blocks-lldap-global-setup ```nix shb.home-assistant.ldap enable = true; host = "127.0.0.1"; port = config.shb.lldap.webUIListenPort; userGroup = "homeassistant_user"; }; ``` And that's it. Now, go to the LDAP server at `http://ldap.example.com`, create the `home-assistant_user` group, create a user and add it to one or both groups. When that's done, go back to the Home-Assistant server at `http://home-assistant.example.com` and login with that user. ### With SSO Support {#services-home-assistant-usage-sso} :::: {.warning} This is not implemented yet. Any contributions ([issue #12](https://github.com/ibizaman/selfhostblocks/issues/12)) are welcomed! :::: ### Backup {#services-home-assistant-usage-backup} Backing up Home-Assistant using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."home-assistant" = { request = config.shb.home-assistant.backup; settings = { enable = true; }; }; ``` The name `"home-assistant"` in the `instances` can be anything. The `config.shb.home-assistant.backup` option provides what directories to backup. You can define any number of Restic instances to backup Home-Assistant multiple times. You will then need to configure more options like the `repository`, as explained in the [restic](blocks-restic.html) documentation. ### Application Dashboard {#services-home-assistant-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-home-assistant-options-shb.home-assistant.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Home.services.HomeAssistant = { sortOrder = 1; dashboard.request = config.shb.home-assistant.dashboard.request; settings.icon = "si-homeassistant"; }; } ``` The icon needs to be set manually otherwise it is not displayed correctly. An API key can be set to show extra info: ```nix { shb.homepage.servicesGroups.Home.services.HomeAssistant = { apiKey.result = config.shb.sops.secret."home-assistant/homepageApiKey".result; }; shb.sops.secret."home-assistant/homepageApiKey".request = config.shb.homepage.servicesGroups.Home.services.HomeAssistant.apiKey.request; } ``` Custom widgets can be set using Home Assistant templating: ```nix { shb.homepage.servicesGroups.Home.services.HomeAssistant = { settings.widget.custom = [ { template = "{{ states('sensor.power_consumption_power_consumption', with_unit=True, rounded=True) }}"; label = "energy now"; } { state = "sensor.power_consumption_daily_power_consumption"; label = "energy today"; } ]; }; } ``` ### Extra Components {#services-home-assistant-usage-extra-components} Packaged components can be found in the documentation of the corresponding option [services.home-assistant.extraComponents](https://search.nixos.org/options?channel=25.05&show=services.home-assistant.extraComponents&from=0&size=50&sort=relevance&type=packages&query=services.home-assistant.extraComponents) [services.home-assistant-extraComponents]: https://search.nixos.org/options?channel=25.05&show=services.home-assistant.extraComponents&from=0&size=50&sort=relevance&type=packages&query=services.home-assistant.extraComponents When you find an interesting one add it to the option: ```bash services.home-assistant.extraComponents = [ "backup" "bluetooth" "esphome" "assist_pipeline" "conversation" "piper" "wake_word" "whisper" "wyoming" ]; ``` Some components are not available as extra components, but need to be added as cusotm components. If the component is not packaged, you'll need to use a [custom component](#services-home-assistant-usage-custom-components). ### Custom Components {#services-home-assistant-usage-custom-components} :::: {.note} I'm still confused for why is there a difference between custom components and extra components. :::: Available custom components can be found by searching packages for [home-assistant-custom-components][]. [home-assistant-custom-components]: https://search.nixos.org/packages?channel=25.05&from=0&size=50&sort=alpha_asc&type=packages&query=home-assistant-custom-components Add them like so: ```nix services.home-assistant.customComponents = with pkgs.home-assistant-custom-components; [ adaptive_lighting ]; ``` To add a not packaged component, you can get inspiration from existing [packaged components. To help you package a custom component [nixpkgs code][component-packages.nix] to package it using the `pkgs.buildHomeAssistantComponent` function. [component-packages.nix]: https://github.com/NixOS/nixpkgs/blob/master/pkgs/servers/home-assistant/component-packages.nix When done, add it to the same `services.home-assistant.customComponents` option. Also, don't hesitate to upstream it to nixpkgs. ### Custom Lovelace Modules {#services-home-assistant-usage-custom-lovelace-modules} To add custom Lovelace UI elements, add them to the `services.home-assistant.customLovelaceModules` option. Available custom components can be found by searching packages for [home-assistant-custom-lovelace-modules][]. [home-assistant-custom-lovelace-modules]: https://search.nixos.org/packages?channel=25.05&from=0&size=50&sort=alpha_asc&type=packages&query=home-assistant-custom-lovelace-modules ```nix services.home-assistant.customLovelaceModules = with pkgs.home-assistant-custom-lovelace-modules; [ mini-graph-card mini-media-player hourly-weather weather-card ]; ``` ### Extra Packages {#services-home-assistant-usage-extra-packages} This is really only needed if by mischance, one of the components added earlier fail because of a missing Python3 package when the home-assistant systemd service is started. Usually, the required module will be shown in the traceback. To know to which nixpkgs package this Python3 package correspond, search for a package in the [python3XXPackages set][]. [python3XXPackages set]: https://search.nixos.org/packages?channel=25.05&from=0&size=50&buckets=%7B%22package_attr_set%22%3A%5B%22python313Packages%22%5D%2C%22package_license_set%22%3A%5B%5D%2C%22package_maintainers_set%22%3A%5B%5D%2C%22package_teams_set%22%3A%5B%5D%2C%22package_platforms%22%3A%5B%5D%7D&sort=alpha_asc&type=packages&query=grpcio ```nix services.home-assistant.extraPackages = python3Packages: with python3Packages; [ grpcio ]; ``` ### Extra Groups {#services-home-assistant-usage-extra-groups} Some components need access to hardware components which mean the home-assistant user `hass` must be added to some Unix group. For example, the `hass` user must be added to the `dialout` group for the Sonoff component. There's no systematic way to know this apart reading the logs when a home-assistant component fails to start. ```nix users.users.hass.extraGroups = [ "dialout" ]; ``` ### Voice {#services-home-assistant-usage-voice} Text to speech (TTS) and speech to text (STT) can be added with the stock nixpkgs options. The most performance hungry one is STT. If you don't have a good CPU or better a GPU, you won't be able to use medium to big models. From my own experience using a low-end CPU, voice is pretty much unusable like that, even with mini models. Here is the configuration I use on a low-end CPU: ```nix shb.home-assistant.voice.text-to-speech = { "fr" = { enable = true; voice = "fr-siwis-medium"; uri = "tcp://0.0.0.0:10200"; speaker = 0; }; "en" = { enable = true; voice = "en_GB-alba-medium"; uri = "tcp://0.0.0.0:10201"; speaker = 0; }; }; shb.home-assistant.voice.speech-to-text = { "tiny-fr" = { enable = true; model = "base-int8"; language = "fr"; uri = "tcp://0.0.0.0:10300"; device = "cpu"; }; "tiny-en" = { enable = true; model = "base-int8"; language = "en"; uri = "tcp://0.0.0.0:10301"; device = "cpu"; }; }; systemd.services.wyoming-faster-whisper-tiny-en.environment."HF_HUB_CACHE" = "/tmp"; systemd.services.wyoming-faster-whisper-tiny-fr.environment."HF_HUB_CACHE" = "/tmp"; shb.home-assistant.voice.wakeword = { enable = true; uri = "tcp://127.0.0.1:10400"; preloadModels = [ "ok_nabu" ]; }; ``` ### Music Assistant {#services-home-assistant-usage-music-assistant} To add Music Assistant under the `ma.example.com` domain with two factor SSO authentication, use the following configuration. This assumes the [SSL][] and [SSO][] blocks are configured. [SSL]: blocks-ssl.html [SSO]: blocks-sso.html ```nix services.music-assistant = { enable = true; providers = [ "airplay" "hass" "hass_players" "jellyfin" "radiobrowser" "sonos" "spotify" ]; }; shb.nginx.vhosts = [ { subdomain = "ma"; domain = "example.com"; ssl = config.shb.certs.certs.letsencrypt.${domain}; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; upstream = "http://127.0.0.1:8095"; autheliaRules = [{ domain = "ma.${domain}"; policy = "two_factor"; subject = ["group:music-assistant_user"]; }]; } ]; ``` ## Debug {#services-home-assistant-debug} In case of an issue, check the logs for systemd service `home-assistant.service`. Enable verbose logging by setting the `shb.home-assistant.debug` boolean to `true`. Access the database with `sudo -u home-assistant psql`. ## Options Reference {#services-home-assistant-options} ```{=include=} options id-prefix: services-home-assistant-options- list-id: selfhostblocks-service-home-assistant-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/home-assistant.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.home-assistant; fqdn = "${cfg.subdomain}.${cfg.domain}"; ldap_auth_script_repo = pkgs.fetchFromGitHub { owner = "lldap"; repo = "lldap"; rev = "7d1f5abc137821c500de99c94f7579761fc949d8"; sha256 = "sha256-8D+7ww70Ja6Qwdfa+7MpjAAHewtCWNf/tuTAExoUrg0="; }; ldap_auth_script = pkgs.writeShellScriptBin "ldap_auth.sh" '' export PATH=${pkgs.gnused}/bin:${pkgs.curl}/bin:${pkgs.jq}/bin exec ${pkgs.bash}/bin/bash ${ldap_auth_script_repo}/example_configs/lldap-ha-auth.sh $@ ''; # Filter secrets from config. Secrets are those of the form { source = ; } secrets = lib.attrsets.filterAttrs (k: v: builtins.isAttrs v) cfg.config; nonSecrets = (lib.attrsets.filterAttrs (k: v: !(builtins.isAttrs v)) cfg.config); configWithSecretsIncludes = nonSecrets // (lib.attrsets.mapAttrs (k: v: "!secret ${k}") secrets); in { imports = [ ../../lib/module.nix ]; options.shb.home-assistant = { enable = lib.mkEnableOption "selfhostblocks.home-assistant"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which home-assistant will be served."; example = "ha"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which home-assistant will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; config = lib.mkOption { description = "See all available settings at https://www.home-assistant.io/docs/configuration/basic/"; type = lib.types.submodule { freeformType = lib.types.attrsOf lib.types.str; options = { name = lib.mkOption { type = lib.types.oneOf [ lib.types.str shb.secretFileType ]; description = "Name of the Home Assistant instance."; }; country = lib.mkOption { type = lib.types.oneOf [ lib.types.str shb.secretFileType ]; description = "Two letter country code where this instance is located."; }; latitude = lib.mkOption { type = lib.types.oneOf [ lib.types.str shb.secretFileType ]; description = "Latitude where this instance is located."; }; longitude = lib.mkOption { type = lib.types.oneOf [ lib.types.str shb.secretFileType ]; description = "Longitude where this instance is located."; }; time_zone = lib.mkOption { type = lib.types.oneOf [ lib.types.str shb.secretFileType ]; description = "Timezone of this instance."; example = "America/Los_Angeles"; }; unit_system = lib.mkOption { type = lib.types.oneOf [ lib.types.str (lib.types.enum [ "metric" "us_customary" ]) ]; description = "Unit system of this instance."; example = "metric"; }; }; }; }; ldap = lib.mkOption { description = '' LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html) Enabling this app will create a new LDAP configuration or update one that exists with the given host. Also, enabling LDAP will skip onboarding otherwise Home Assistant gets into a cyclic lock. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "LDAP app."; host = lib.mkOption { type = lib.types.str; description = '' Host serving the LDAP server. If set, the Home Assistant auth will be disabled. To keep it, set `keepDefaultAuth` to `true`. ''; default = "127.0.0.1"; }; port = lib.mkOption { type = lib.types.port; description = '' Port of the service serving the LDAP server. ''; default = 389; }; userGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to login to Nextcloud."; default = "homeassistant_user"; }; keepDefaultAuth = lib.mkOption { type = lib.types.bool; description = '' Keep Home Assistant auth active, even if LDAP is configured. Usually, you want to enable this to transfer existing users to LDAP and then you can disabled it. ''; default = false; }; }; }; }; voice = lib.mkOption { description = "Options related to voice service."; default = { }; type = lib.types.submodule { options = { speech-to-text = lib.mkOption { description = '' Wyoming piper servers. https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.piper.servers ''; type = lib.types.attrsOf lib.types.anything; default = { }; }; text-to-speech = lib.mkOption { description = '' Wyoming faster-whisper servers. https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.faster-whisper.servers ''; type = lib.types.attrsOf lib.types.anything; default = { }; }; wakeword = lib.mkOption { description = '' Wyoming open wakework servers. https://search.nixos.org/options?channel=23.11&from=0&size=50&sort=relevance&type=packages&query=services.wyoming.openwakeword ''; type = lib.types.anything; default = { enable = false; }; }; }; }; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "hass"; # No need for backup hooks as we use an hourly automation job in home assistant directly with a cron job. sourceDirectories = [ "/var/lib/hass/backups" ]; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.home-assistant.subdomain}.\${config.shb.home-assistant.domain}"; internalUrl = "http://127.0.0.1:${toString config.services.home-assistant.config.http.server_port}"; internalUrlText = "http://127.0.0.1:\${config.services.home-assistant.config.http.server_port}"; }; }; }; }; config = lib.mkIf cfg.enable { services.home-assistant = { enable = true; # Find them at https://github.com/NixOS/nixpkgs/blob/master/pkgs/servers/home-assistant/component-packages.nix extraComponents = [ # Components required to complete the onboarding "met" "radio_browser" ]; configDir = "/var/lib/hass"; # If you can't find a component in component-packages.nix, you can add them manually with something similar to: # extraPackages = python3Packages: [ # (python3Packages.simplisafe-python.overrideAttrs (old: rec { # pname = "simplisafe-python"; # version = "5b003a9fa1abd00f0e9a0b99d3ee57c4c7c16bda"; # format = "pyproject"; # src = pkgs.fetchFromGitHub { # owner = "bachya"; # repo = pname; # rev = "${version}"; # hash = "sha256-Ij2e0QGYLjENi/yhFBQ+8qWEJp86cgwC9E27PQ5xNno="; # }; # })) # ]; config = { # Includes dependencies for a basic setup # https://www.home-assistant.io/integrations/default_config/ default_config = { }; http = { use_x_forwarded_for = true; server_host = "127.0.0.1"; server_port = 8123; trusted_proxies = "127.0.0.1"; }; logger.default = "info"; homeassistant = configWithSecretsIncludes // { external_url = "https://${cfg.subdomain}.${cfg.domain}"; internal_url = "https://${cfg.subdomain}.${cfg.domain}"; auth_providers = (lib.optionals (!cfg.ldap.enable || cfg.ldap.keepDefaultAuth) [ { type = "homeassistant"; } ]) ++ (lib.optionals cfg.ldap.enable [ { type = "command_line"; command = ldap_auth_script + "/bin/ldap_auth.sh"; args = [ "http://${cfg.ldap.host}:${toString cfg.ldap.port}" cfg.ldap.userGroup ]; meta = true; } ]); }; "automation ui" = "!include automations.yaml"; "scene ui" = "!include scenes.yaml"; "script ui" = "!include scripts.yaml"; "automation manual" = [ { alias = "Create Backup on Schedule"; trigger = [ { platform = "time_pattern"; minutes = "5"; } ]; action = [ { service = "shell_command.delete_backups"; data = { }; } { service = "backup.create"; data = { }; } ]; mode = "single"; } ]; shell_command = { delete_backups = "find ${config.services.home-assistant.configDir}/backups -type f -delete"; }; conversation.intents = { TellJoke = [ "Tell [me] (a joke|something funny|a dad joke)" "Raconte [moi] (une blague)" ]; }; sensor = [ { name = "random_joke"; platform = "rest"; json_attributes = [ "joke" "id" "status" ]; value_template = "{{ value_json.joke }}"; resource = "https://icanhazdadjoke.com/"; scan_interval = "3600"; headers.Accept = "application/json"; } ]; intent_script.TellJoke = { speech.text = ''{{ state_attr("sensor.random_joke", "joke") }}''; action = { service = "homeassistant.update_entity"; entity_id = "sensor.random_joke"; }; }; }; }; services.wyoming.piper.servers = cfg.voice.text-to-speech; services.wyoming.faster-whisper.servers = cfg.voice.speech-to-text; services.wyoming.openwakeword = cfg.voice.wakeword; services.nginx.virtualHosts."${fqdn}" = { http2 = true; forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; extraConfig = '' proxy_buffering off; ''; locations."/" = { proxyPass = "http://${toString config.services.home-assistant.config.http.server_host}:${toString config.services.home-assistant.config.http.server_port}/"; proxyWebsockets = true; }; }; systemd.services.home-assistant.preStart = ( let # TODO: this probably does not work anymore onboarding = pkgs.writeText "onboarding" '' { "version": 4, "minor_version": 1, "key": "onboarding", "data": { "done": [ ${lib.optionalString cfg.ldap.enable ''"user",''} "core_config", "analytics" ] } } ''; storage = "${config.services.home-assistant.configDir}"; file = "${storage}/.storage/onboarding"; in '' if [ ! -f ${file} ]; then mkdir -p ''$(dirname ${file}) && cp ${onboarding} ${file} fi '' ) + (shb.replaceSecrets { userConfig = cfg.config; resultPath = "${config.services.home-assistant.configDir}/secrets.yaml"; generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toYAML { }); }); systemd.tmpfiles.rules = [ "f ${config.services.home-assistant.configDir}/automations.yaml 0755 hass hass" "f ${config.services.home-assistant.configDir}/scenes.yaml 0755 hass hass" "f ${config.services.home-assistant.configDir}/scripts.yaml 0755 hass hass" "d /var/lib/hass/backups 0750 hass hass" ]; }; } ================================================ FILE: modules/services/homepage/docs/default.md ================================================ # Homepage Service {#services-homepage} Defined in [`/modules/services/homepage.nix`](@REPO@/modules/services/homepage.nix), found in the `selfhostblocks.nixosModules.homepage` module. See [the manual](usage.html#usage-flake) for how to import the module in your code. This service sets up [Homepage Dashboard][] which provides a highly customizable homepage Docker and service API integrations. ![](./Screenshot.png) [Homepage Dashboard]: https://github.com/gethomepage/homepage ## Features {#services-homepage-features} - Declarative SSO login through forward authentication. Only users of the [Homepage LDAP user group][] can access the web UI. This is enforced using the [Authelia block][] which integrates with the LLDAP block. - Access through [subdomain][] using the reverse proxy. It is implemented with the [Nginx block][]. - Access through [HTTPS][] using the reverse proxy. It is implemented with the [SSL block][]. - Integration with [secrets contract][] to set the API key for a widget. [Homepage LDAP user group]: #services-homepage-options-shb.homepage.ldap.userGroup [Authelia block]: blocks-authelia.html [subdomain]: #services-open-webui-options-shb.open-webui.subdomain [HTTPS]: #services-open-webui-options-shb.open-webui.ssl [Nginx block]: blocks-nginx.html [SSL block]: blocks-ssl.html [secrets contract]: contracts-secret.html ::: {.note} The service does not use state so no backup or impermanence integration is provided. ::: ## Usage {#services-homepage-usage} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ::: {.note} Part of the configuration is done through the `shb.homepage` option described here and the rest is done through the upstream [`services.homepage-dashboard`][] option. ::: [`services.homepage-dashboard`]: https://search.nixos.org/options?query=services.homepage-dashboard ### Main service configuration {#services-homepage-usage-main} This part sets up the web UI and its integration with the other SHB services. It also creates the various service groups which will hold each service. The names are arbitrary and you can order them as you wish through the `sortOrder` option. ```nix { shb.certs.certs.letsencrypt.${domain}.extraDomains = [ "${config.shb.homepage.subdomain}.${config.shb.homepage.domain}" ]; shb.homepage = { enable = true; subdomain = "home"; inherit domain; ssl = config.shb.certs.certs.letsencrypt.${domain}; sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; servicesGroups = { Home.sortOrder = 1; Documents.sortOrder = 2; Finance.sortOrder = 3; Media.sortOrder = 4; Admin.sortOrder = 5; }; }; services.homepage-dashboard = { settings = { statusStyle = "dot"; disableIndexing = true; }; widgets = [ { datetime = { locale = "fr"; format = { dateStyle = "long"; timeStyle = "long"; }; }; } ]; }; } ``` The [Homepage LDAP user group][] is created automatically and users can be added declaratively to the group with:. ```nix { shb.lldap.ensureUsers.${user}.groups = [ config.shb.homepage.ldap.userGroup ]; } ``` ### Display SHB service {#services-homepage-usage-service} A service consumer of the dashboard contract provides a `dashboard` option that can be used like so: ```nix { shb.homepage.servicesGroups.Media.services.Jellyfin = { sortOrder = 2; dashboard.request = config.shb.jellyfin.dashboard.request; }; } ``` By default: - The `serviceName` option comes from the attr name, here `Jellyfin`. - The `icon` option comes from applying `toLower` on the attr name. - The `siteMonitor` option is set only if `internalUrl` is set. They can be overridden by setting them in the [settings](#services-homepage-options-shb.homepage.servicesGroups._name_.services._name_.settings option) (see option documentation for examples): ```nix { shb.homepage.servicesGroups.Media.services.Jellyfin = { sortOrder = 2; dashboard.request = config.shb..dashboard.request; settings = { // custom options here. }; }; } ``` Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. ### Display custom service {#services-homepage-usage-custom} To display a service that does not provide a `dashboard` option like in the previous section, set the values of the request manually: ```nix { shb.homepage.servicesGroups.Media.services.Jellyfin = { sortOrder = 2; dashboard.request = { externalUrl = "https://jellyfin.example.com"; internalUrl = "http://127.0.0.1:8081"; }; }; } ``` ### Add API key for widget {#services-homepage-usage-widget} For services [supporting a widget](https://gethomepage.dev/widgets/), create an API key through the service's web UI if available then store it securely (using SOPS for example) and provide it through the `apiKey` option: ```nix { shb.homepage.servicesGroups.Media.services.Jellyfin = { sortOrder = 1; dashboard.request = config.shb.jellyfin.dashboard.request; apiKey.result = config.shb.sops.secret."jellyfin/homepageApiKey".result; }; shb.sops.secret."jellyfin/homepageApiKey".request = config.shb.homepage.servicesGroups.Media.services.Jellyfin.apiKey.request; } ``` Unfortunately creating API keys declaratively is rarely supported by upstream services. ## Options Reference {#services-homepage-options} ```{=include=} options id-prefix: services-homepage-options- list-id: selfhostblocks-service-homepage-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/homepage.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.homepage; inherit (lib) types; in { imports = [ ../../lib/module.nix ../blocks/lldap.nix ../blocks/nginx.nix ]; options.shb.homepage = { enable = lib.mkEnableOption "the SHB homepage service"; subdomain = lib.mkOption { type = types.str; description = '' Subdomain under which homepage will be served. ``` . ``` ''; example = "homepage"; }; domain = lib.mkOption { description = '' Domain under which homepage is served. ``` . ``` ''; type = types.str; example = "domain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = types.nullOr shb.contracts.ssl.certs; default = null; }; servicesGroups = lib.mkOption { description = "Group of services that should be showed on the dashboard."; default = { }; type = types.attrsOf ( types.submodule ( { name, ... }: { options = { name = lib.mkOption { type = types.str; description = "Display name of the group. Defaults to the attr name."; default = name; }; sortOrder = lib.mkOption { description = '' Order in which groups will be shown. The rules are: - Lowest number is shown first. - Two groups having the same number are shown in a consistent (same across multiple deploys) but undefined order. - Default is null which means at the end. ''; type = types.nullOr types.int; default = null; }; services = lib.mkOption { description = "Services that should be showed in the group on the dashboard."; default = { }; type = types.attrsOf ( types.submodule ( { name, ... }: { options = { name = lib.mkOption { type = types.str; description = "Display name of the service. Defaults to the attr name."; default = name; }; sortOrder = lib.mkOption { type = types.nullOr types.int; description = '' Order in which groups will be shown. The rules are: - Lowest number is shown first. - Two groups having the same number are shown in a consistent (same across multiple deploys) but undefined order. - Default is null which means at the end. ''; default = null; }; dashboard = lib.mkOption { description = '' Provider of the dashboard contract. By default: - The `serviceName` option comes from the attr name. - The `icon` option comes from applying `toLower` on the attr name. - The `siteMonitor` option is set only if `internalUrl` is set. ''; type = types.submodule { options = shb.contracts.dashboard.mkProvider { resultCfg = { }; }; }; }; apiKey = lib.mkOption { description = '' API key used to access the service. This can be used to get data from the service. ''; default = null; type = types.nullOr ( lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "root"; restartUnits = [ "homepage-dashboard.service" ]; }; } ); }; settings = lib.mkOption { description = '' Extra options to pass to the homepage service. Check https://gethomepage.dev/configs/services/#icons if the default icon is not correct. And check https://gethomepage.dev/widgets if the default widget type is not correct. ''; default = { }; type = types.attrsOf types.anything; example = lib.literalExpression '' { icon = "si-homeassistant"; widget.type = "firefly"; widget.custom = [ { template = "{{ states('sensor.total_power', with_unit=True, rounded=True) }}"; label = "energy now"; } { state = "sensor.total_power_today"; label = "energy today"; } ]; } ''; }; }; } ) ); }; }; } ) ); }; ldap = lib.mkOption { description = '' Setup LDAP integration. ''; default = { }; type = types.submodule { options = { userGroup = lib.mkOption { type = types.str; description = "Group users must belong to be able to login."; default = "homepage_user"; }; }; }; }; sso = lib.mkOption { description = '' Setup SSO integration. ''; default = { }; type = types.submodule { options = { enable = lib.mkEnableOption "SSO integration."; authEndpoint = lib.mkOption { type = lib.types.str; description = "Endpoint to the SSO provider."; example = "https://authelia.example.com"; }; authorization_policy = lib.mkOption { type = types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; }; }; }; }; config = lib.mkIf cfg.enable { services.homepage-dashboard = { enable = true; allowedHosts = "${cfg.subdomain}.${cfg.domain}"; settings = { baseUrl = "https://${cfg.subdomain}.${cfg.domain}"; startUrl = "https://${cfg.subdomain}.${cfg.domain}"; disableUpdateCheck = true; }; bookmarks = [ ]; services = shb.homepage.asServiceGroup cfg.servicesGroups; widgets = [ ]; }; systemd.services.homepage-dashboard.serviceConfig = let keys = shb.homepage.allKeys cfg.servicesGroups; in { # LoadCredential = [ # "Media_Jellyfin:/path" # ]; LoadCredential = lib.mapAttrsToList (name: path: "${name}:${path}") keys; # Environment = [ # "HOMEPAGE_FILE_Media_Jellyfin=%d/Media_Jellyfin" # ]; Environment = lib.mapAttrsToList (name: path: "HOMEPAGE_FILE_${name}=%d/${name}") keys; }; # This should be using a contract instead of setting the option directly. shb.lldap = lib.mkIf config.shb.lldap.enable { ensureGroups = { ${cfg.ldap.userGroup} = { }; }; }; shb.nginx.vhosts = [ ( { inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString config.services.homepage-dashboard.listenPort}/"; extraConfig = '' proxy_read_timeout 300s; proxy_send_timeout 300s; ''; autheliaRules = lib.optionals (cfg.sso.enable) [ { domain = "${cfg.subdomain}.${cfg.domain}"; policy = cfg.sso.authorization_policy; subject = [ "group:${cfg.ldap.userGroup}" ]; } ]; } // lib.optionalAttrs cfg.sso.enable { inherit (cfg.sso) authEndpoint; } ) ]; }; } ================================================ FILE: modules/services/immich.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.immich; fqdn = "${cfg.subdomain}.${cfg.domain}"; protocol = if !(isNull cfg.ssl) then "https" else "http"; roleClaim = "immich_user"; # TODO: Quota management, see https://github.com/ibizaman/selfhostblocks/pull/523#discussion_r2309421694 #quotaClaim = "immich_quota"; scopes = [ "openid" "email" "profile" "groups" "immich_scope" ]; dataFolder = cfg.mediaLocation; ssoFqdnWithPort = if isNull cfg.sso.port then cfg.sso.endpoint else "${cfg.sso.endpoint}:${toString cfg.sso.port}"; # Generate Immich configuration file only for SHB-managed settings shbManagedSettings = lib.optionalAttrs (cfg.settings != { }) cfg.settings // lib.optionalAttrs (cfg.sso.enable) { oauth = { enabled = true; issuerUrl = "${ssoFqdnWithPort}"; clientId = cfg.sso.clientID; roleClaim = roleClaim; clientSecret = { source = cfg.sso.sharedSecret.result.path; }; scope = builtins.concatStringsSep " " scopes; storageLabelClaim = cfg.sso.storageLabelClaim; #storageQuotaClaim = quotaClaim; # TODO (commented out, otherwise defaults to 0 bytes!) defaultStorageQuota = 0; buttonText = cfg.sso.buttonText; autoRegister = cfg.sso.autoRegister; autoLaunch = cfg.sso.autoLaunch; passwordLogin = cfg.sso.passwordLogin; mobileOverrideEnabled = false; mobileRedirectUri = ""; }; } // lib.optionalAttrs (cfg.smtp != null) { notifications = { smtp = { enabled = true; from = cfg.smtp.from; replyTo = cfg.smtp.replyTo; transport = { host = cfg.smtp.host; port = cfg.smtp.port; username = cfg.smtp.username; password = { source = cfg.smtp.password.result.path; }; ignoreTLS = cfg.smtp.ignoreTLS; secure = cfg.smtp.secure; }; }; }; }; configFile = "/var/lib/immich/config.json"; # Use SHB's replaceSecrets function for loading secrets at runtime configSetupScript = lib.optionalString (cfg.sso.enable || cfg.smtp != null) ( shb.replaceSecrets { userConfig = shbManagedSettings; resultPath = configFile; generator = shb.replaceSecretsFormatAdapter (pkgs.formats.json { }); user = "immich"; permissions = "u=r,g=,o="; } ); inherit (lib) mkEnableOption mkIf lists mkOption optionals ; inherit (lib.types) attrs attrsOf bool enum listOf nullOr port submodule str path ; in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ]; options.shb.immich = { enable = mkEnableOption "selfhostblocks.immich"; subdomain = mkOption { type = str; description = '' Subdomain under which Immich will be served. ``` . ``` ''; example = "photos"; }; domain = mkOption { description = '' Domain under which Immich is served. ``` . ``` ''; type = str; example = "example.com"; }; port = mkOption { description = '' Port under which Immich will listen. ''; type = port; default = 2283; }; publicProxyEnable = mkOption { description = '' Enable Immich Public Proxy service for sharing media publically. ''; type = bool; default = false; }; publicProxyPort = mkOption { description = '' Port under which Immich Public Proxy will listen. ''; type = port; default = 2284; }; ssl = mkOption { description = "Path to SSL files"; type = nullOr shb.contracts.ssl.certs; default = null; }; mediaLocation = mkOption { description = "Directory where Immich will store media files."; type = str; default = "/var/lib/immich"; }; jwtSecretFile = mkOption { description = '' File containing Immich's JWT secret key for sessions. This is required for secure session management. ''; type = nullOr (submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "immich"; restartUnits = [ "immich-server.service" ]; }; }); default = null; }; mount = mkOption { type = shb.contracts.mount; description = '' Mount configuration. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."immich" = { poolName = "root"; } // config.shb.immich.mount; ``` ''; readOnly = true; default = { path = dataFolder; }; }; backup = mkOption { description = '' Backup configuration for Immich media files and database. ''; default = { }; type = submodule { options = shb.contracts.backup.mkRequester { user = "immich"; sourceDirectories = [ dataFolder ]; excludePatterns = [ "*.tmp" "cache/*" "encoded-video/*" ]; }; }; }; accelerationDevices = mkOption { description = '' Hardware acceleration devices for Immich. Set to null to allow access to all devices. Set to empty list to disable hardware acceleration. ''; type = nullOr (listOf path); default = null; example = [ "/dev/dri" ]; }; machineLearning = mkOption { description = "Machine learning configuration."; default = { }; type = submodule { options = { enable = mkOption { description = "Enable machine learning features."; type = bool; default = true; }; environment = mkOption { description = "Extra environment variables for machine learning service."; type = attrsOf str; default = { }; example = { MACHINE_LEARNING_WORKERS = "2"; MACHINE_LEARNING_WORKER_TIMEOUT = "180"; }; }; }; }; }; sso = mkOption { description = '' Setup SSO integration. ''; default = { }; type = submodule { options = { enable = mkEnableOption "SSO integration."; provider = mkOption { type = enum [ "Authelia" "Keycloak" "Generic" ]; description = "OIDC provider name, used for display."; default = "Authelia"; }; endpoint = mkOption { type = str; description = "OIDC endpoint for SSO."; example = "https://authelia.example.com"; }; clientID = mkOption { type = str; description = "Client ID for the OIDC endpoint."; default = "immich"; }; adminUserGroup = lib.mkOption { type = lib.types.str; description = "OIDC admin group"; default = "immich_admin"; }; userGroup = lib.mkOption { type = lib.types.str; description = "OIDC user group"; default = "immich_user"; }; port = mkOption { description = "If given, adds a port to the endpoint."; type = nullOr port; default = null; }; storageLabelClaim = mkOption { type = str; description = "Claim to use for user storage label."; default = "preferred_username"; }; buttonText = mkOption { type = str; description = "Text to display on the SSO login button."; default = "Login with SSO"; }; autoRegister = mkOption { type = bool; description = "Automatically register new users from SSO provider."; default = true; }; autoLaunch = mkOption { type = bool; description = "Automatically redirect to SSO provider."; default = true; }; passwordLogin = mkOption { type = bool; description = "Enable password login."; default = true; }; sharedSecret = mkOption { description = "OIDC shared secret for Immich."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "immich"; group = "immich"; restartUnits = [ "immich-server.service" ]; }; }; }; sharedSecretForAuthelia = mkOption { description = "OIDC shared secret for Authelia. Content must be the same as `sharedSecret` option."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "authelia"; }; }; default = null; }; authorization_policy = mkOption { type = enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; }; }; }; settings = mkOption { type = attrs; description = '' Immich configuration settings. Only specify settings that you want SHB to manage declaratively. Other settings can be configured through Immich's admin UI. See https://immich.app/docs/install/config-file/ for available options. ''; default = { }; example = { ffmpeg.crf = 23; job.backgroundTask.concurrency = 5; storageTemplate = { enabled = true; template = "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"; }; }; }; smtp = mkOption { description = '' SMTP configuration for sending notifications. ''; default = null; type = nullOr (submodule { options = { from = mkOption { type = str; description = "SMTP address from which the emails originate."; example = "noreply@example.com"; }; replyTo = mkOption { type = str; description = "Reply-to address for emails."; example = "support@example.com"; }; host = mkOption { type = str; description = "SMTP host to send the emails to."; example = "smtp.example.com"; }; port = mkOption { type = port; description = "SMTP port to send the emails to."; default = 587; }; username = mkOption { type = str; description = "Username to connect to the SMTP host."; example = "smtp-user"; }; password = mkOption { description = "File containing the password to connect to the SMTP host."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "immich"; restartUnits = [ "immich-server.service" ]; }; }; }; ignoreTLS = mkOption { type = bool; description = "Ignore TLS certificate errors."; default = false; }; secure = mkOption { type = bool; description = "Use secure connection (SSL/TLS)."; default = false; }; }; }); }; debug = mkOption { type = bool; description = "Set to true to enable debug logging."; default = false; example = true; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${fqdn}"; externalUrlText = "https://\${config.shb.immich.subdomain}.\${config.shb.immich.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.port}"; }; }; }; }; config = mkIf cfg.enable { assertions = [ { assertion = !(isNull cfg.ssl) -> !(isNull cfg.ssl.paths.cert) && !(isNull cfg.ssl.paths.key); message = "SSL is enabled for Immich but no cert or key is provided."; } { assertion = cfg.sso.enable -> cfg.ssl != null; message = "To integrate SSO, SSL must be enabled, set the shb.immich.ssl option."; } ]; # Configure Immich service services.immich = { enable = true; host = "127.0.0.1"; port = cfg.port; mediaLocation = cfg.mediaLocation; # Hardware acceleration configuration accelerationDevices = cfg.accelerationDevices; # Database configuration defaults to Unix socket /run/postgresql # Machine learning configuration machine-learning = mkIf cfg.machineLearning.enable { enable = true; environment = cfg.machineLearning.environment; }; # Environment configuration environment = { IMMICH_LOG_LEVEL = if cfg.debug then "debug" else "log"; REDIS_HOSTNAME = "127.0.0.1"; REDIS_PORT = "6379"; REDIS_DBINDEX = "0"; } // lib.optionalAttrs (cfg.jwtSecretFile != null) { JWT_SECRET_FILE = cfg.jwtSecretFile.result.path; } // lib.optionalAttrs (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) { IMMICH_CONFIG_FILE = configFile; }; }; services.immich-public-proxy = mkIf (cfg.publicProxyEnable) { enable = true; port = cfg.publicProxyPort; immichUrl = "https://${fqdn}"; }; # Create basic directories for Immich systemd.tmpfiles.rules = [ "d /var/lib/immich 0700 immich immich" ]; # Configuration setup service - generates config only for SHB-managed settings systemd.services.immich-setup-config = mkIf (cfg.enable && (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null)) { description = "Setup Immich configuration for SHB-managed settings"; wantedBy = [ "multi-user.target" ]; before = [ "immich-server.service" ]; after = [ "network.target" ]; serviceConfig = { Type = "oneshot"; User = "immich"; Group = "immich"; }; script = '' mkdir -p ${dataFolder} # Generate config file with only SHB-managed settings ${configSetupScript} ''; }; # Add immich user to video and render groups for hardware acceleration users.users.immich.extraGroups = optionals (cfg.accelerationDevices != [ ]) [ "video" "render" ]; # PostgreSQL extensions are automatically handled by the Immich service # Redis is automatically configured by the Immich service # Configure Nginx reverse proxy shb.nginx.vhosts = [ { inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString cfg.port}"; autheliaRules = lib.mkIf (cfg.sso.enable) [ { domain = fqdn; policy = "bypass"; resources = [ "^/api.*" "^/.well-known/immich" "^/share.*" "^/_app/immutable/.*" ]; } { domain = fqdn; policy = cfg.sso.authorization_policy; subject = [ "group:immich_user" "group:immich_admin" ]; } ]; authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint; extraConfig = '' proxy_read_timeout 600s; proxy_send_timeout 600s; send_timeout 600s; proxy_buffering off; ''; } ]; # Allow large uploads from mobile app services.nginx.virtualHosts."${fqdn}" = { extraConfig = '' client_max_body_size 50G; ''; locations."^~ /share" = { recommendedProxySettings = true; proxyPass = "http://127.0.0.1:${toString cfg.publicProxyPort}"; }; }; # Ensure services start in correct order systemd.services.immich-server = { after = [ "postgresql.service" "redis-immich.service" ] ++ optionals (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) [ "immich-setup-config.service" ]; requires = [ "postgresql.service" "redis-immich.service" ] ++ optionals (cfg.settings != { } || cfg.sso.enable || cfg.smtp != null) [ "immich-setup-config.service" ]; }; systemd.services.immich-machine-learning = mkIf cfg.machineLearning.enable { after = [ "immich-server.service" ]; }; # Authelia integration for SSO shb.authelia.extraDefinitions = { # Immich expects all users that get a token to be granted access. So users can either be part of the # "admin" group or the "user" group. Users that are not part of either should be blocked by # the ID provider (Authelia). user_attributes.${roleClaim}.expression = ''"${cfg.sso.adminUserGroup}" in groups ? "admin" : "user"''; }; shb.authelia.extraOidcClaimsPolicies.immich_policy = { custom_claims = { ${roleClaim} = { }; }; }; shb.authelia.extraOidcScopes.immich_scope = { claims = [ roleClaim ]; }; shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == "Authelia") [ { client_id = cfg.sso.clientID; client_name = "Immich"; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; public = false; authorization_policy = cfg.sso.authorization_policy; claims_policy = "immich_policy"; token_endpoint_auth_method = "client_secret_post"; redirect_uris = [ "${protocol}://${fqdn}/auth/login" "${protocol}://${fqdn}/user-settings" "app.immich:///oauth-callback" ]; inherit scopes; } ]; }; } ================================================ FILE: modules/services/jellyfin/docs/default.md ================================================ # Jellyfin Service {#services-jellyfin} Defined in [`/modules/services/jellyfin.nix`](@REPO@/modules/services/jellyfin.nix). This NixOS module is a service that sets up a [Jellyfin](https://jellyfin.org/) instance. Compared to the stock module from nixpkgs, this one sets up, in a fully declarative manner: - the initial wizard with an admin user thanks to a custom Jellyfin CLI and a custom restart logic to apply the changes from the CLI. - LDAP and SSO integration thanks to a custom declarative installation of plugins. ## Features {#services-jellyfin-features} - Declarative creation of admin user. - Declarative selection of listening port. - Access through [subdomain](#services-jellyfin-options-shb.jellyfin.subdomain) and [HTTPS](#services-jellyfin-options-shb.jellyfin.ssl) using reverse proxy. [Manual](#services-jellyfin-usage). - Declarative plugin installation. [Manual](#services-jellyfin-options-shb.jellyfin.plugins). - Declarative [LDAP](#services-jellyfin-options-shb.jellyfin.ldap) configuration. - Declarative [SSO](#services-jellyfin-options-shb.jellyfin.sso) configuration. - [Backup](#services-jellyfin-options-shb.jellyfin.backup) through the [backup block](./blocks-backup.html). [Manual](#services-jellyfin-usage-backup). - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-jellyfin-usage-applicationdashboard) ## Usage {#services-jellyfin-usage} ### Initial Configuration {#services-jellyfin-usage-configuration} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ```nix shb.jellyfin = { enable = true; subdomain = "jellyfin"; domain = "example.com"; admin = { username = "admin"; password.result = config.shb.sops.secret."jellyfin/adminPassword".result; }; ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminPassword.result = config.shb.sops.secret."jellyfin/ldap/adminPassword".result }; sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; secretFile = config.shb.sops.secret."jellyfin/sso_secret".result; secretFileForAuthelia = config.shb.sops.secret."jellyfin/authelia/sso_secret".result; }; }; shb.sops.secret."jellyfin/adminPassword".request = config.shb.jellyfin.admin.password.request; shb.sops.secret."jellyfin/ldap/adminPassword".request = config.shb.jellyfin.ldap.adminPassword.request; shb.sops.secret."jellyfin/sso_secret".request = config.shb.jellyfin.sso.sharedSecret.request; shb.sops.secret."jellyfin/authelia/sso_secret" = { request = config.shb.jellyfin.sso.sharedSecretForAuthelia.request; settings.key = "jellyfin/sso_secret"; }; ``` Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. The [user](#services-jellyfin-options-shb.jellyfin.ldap.userGroup) and [admin](#services-jellyfin-options-shb.jellyfin.ldap.adminGroup) LDAP groups are created automatically. The `shb.jellyfin.sso.secretFile` and `shb.jellyfin.sso.secretFileForAuthelia` options must have the same content. The former is a file that must be owned by the `jellyfin` user while the latter must be owned by the `authelia` user. I want to avoid needing to define the same secret twice with a future secrets SHB block. ### Certificates {#services-jellyfin-certs} For Let's Encrypt certificates, add: ```nix { shb.certs.certs.letsencrypt.${domain}.extraDomains = [ "${config.shb.jellyfin.subdomain}.${config.shb.jellyfin.domain}" ]; } ``` ### Backup {#services-jellyfin-usage-backup} Backing up Jellyfin using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."jellyfin" = { request = config.shb.jellyfin.backup; settings = { enable = true; }; }; ``` The name `"jellyfin"` in the `instances` can be anything. The `config.shb.jellyfin.backup` option provides what directories to backup. You can define any number of Restic instances to backup Jellyfin multiple times. You will then need to configure more options like the `repository`, as explained in the [restic](blocks-restic.html) documentation. ### Impermanence {#services-jellyfin-impermanence} To save the data folder in an impermanence setup, add: ```nix { shb.zfs.datasets."safe/jellyfin".path = config.shb.jellyfin.impermanence; } ``` ### Declarative LDAP {#services-jellyfin-declarative-ldap} To add a user `USERNAME` to the user and admin groups for jellyfin, add: ```nix shb.lldap.ensureUsers.USERNAME.groups = [ config.shb.jellyfin.ldap.userGroup config.shb.jellyfin.ldap.adminGroup ]; ``` ### Application Dashboard {#services-jellyfin-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-jellyfin-options-shb.jellyfin.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Media.services.Jellyfin = { sortOrder = 1; dashboard.request = config.shb.jellyfin.dashboard.request; }; } ``` An API key can be set to show extra info: ```nix { shb.homepage.servicesGroups.Media.services.Jellyfin = { apiKey.result = config.shb.sops.secret."jellyfin/homepageApiKey".result; }; shb.sops.secret."jellyfin/homepageApiKey".request = config.shb.homepage.servicesGroups.Media.services.Jellyfin.apiKey.request; } ``` ## Debug {#services-jellyfin-debug} In case of an issue, check the logs for systemd service `jellyfin.service`. Enable verbose logging by setting the `shb.jellyfin.debug` boolean to `true`. ## Options Reference {#services-jellyfin-options} ```{=include=} options id-prefix: services-jellyfin-options- list-id: selfhostblocks-service-jellyfin-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/jellyfin.nix ================================================ { config, lib, pkgs, shb, ... }: let inherit (lib) types; cfg = config.shb.jellyfin; fqdn = "${cfg.subdomain}.${cfg.domain}"; jellyfin = pkgs.buildDotnetModule rec { pname = "jellyfin"; version = "10.11.6"; src = pkgs.fetchFromGitHub { owner = "ibizaman"; repo = "jellyfin"; rev = "c58ca41d9ee76d137be788cd6f2d089e288ad561"; hash = "sha256-gTHsz5qRT+9FjAqBb4hDBkHChYDU52snBWu6cQb10i4="; }; propagatedBuildInputs = [ pkgs.sqlite ]; projectFile = "Jellyfin.Server/Jellyfin.Server.csproj"; executables = [ "jellyfin" ]; nugetDeps = "${pkgs.path}/pkgs/by-name/je/jellyfin/nuget-deps.json"; runtimeDeps = [ pkgs.jellyfin-ffmpeg pkgs.fontconfig pkgs.freetype ]; dotnet-sdk = pkgs.dotnetCorePackages.sdk_9_0; dotnet-runtime = pkgs.dotnetCorePackages.aspnetcore_9_0; dotnetBuildFlags = [ "--no-self-contained" ]; makeWrapperArgs = [ "--append-flags" "--ffmpeg=${pkgs.jellyfin-ffmpeg}/bin/ffmpeg" "--append-flags" "--webdir=${pkgs.jellyfin-web}/share/jellyfin-web" ]; passthru.tests = { smoke-test = pkgs.nixosTests.jellyfin; }; meta = with pkgs.lib; { description = "Free Software Media System"; homepage = "https://jellyfin.org/"; # https://github.com/jellyfin/jellyfin/issues/610#issuecomment-537625510 license = licenses.gpl2Plus; maintainers = with maintainers; [ nyanloutre minijackson purcell jojosch ]; mainProgram = "jellyfin"; platforms = dotnet-runtime.meta.platforms; }; }; pluginName = src: let meta = builtins.fromJSON (builtins.readFile "${src}/meta.json"); in "${meta.name}_${meta.version}"; pluginNamePrefix = src: let meta = builtins.fromJSON (builtins.readFile "${src}/meta.json"); in "${meta.name}"; in { options.shb.jellyfin = { enable = lib.mkEnableOption "shb jellyfin"; subdomain = lib.mkOption { type = types.str; description = "Subdomain under which home-assistant will be served."; example = "jellyfin"; }; domain = lib.mkOption { description = "Domain to serve sites under."; type = types.str; example = "domain.com"; }; port = lib.mkOption { description = "Listen on port."; type = types.port; default = 8096; }; ssl = lib.mkOption { description = "Path to SSL files"; type = types.nullOr shb.contracts.ssl.certs; default = null; }; debug = lib.mkOption { description = "Enable debug logging"; type = types.bool; default = false; }; admin = lib.mkOption { description = "Default admin user info. Only needed if LDAP or SSO is not configured."; default = null; type = types.nullOr ( types.submodule { options = { username = lib.mkOption { description = "Username of the default admin user."; type = types.str; default = "jellyfin"; }; password = lib.mkOption { description = "Password of the default admin user."; type = types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "jellyfin"; group = "jellyfin"; restartUnits = [ "jellyfin.service" ]; }; }; }; }; } ); }; plugins = lib.mkOption { description = '' Install plugins declaratively. The LDAP and SSO plugins will be added if their respective shb.jellyfin.ldap.enable and shb.jellyfin.sso.enable options are set to true. The interface for plugin creation is WIP. Feel free to add yours following the examples from the LDAP and SSO plugins but know that they may require some tweaks later on. Notably, configuration is not yet handled by this option so that will be added in the future. Each plugin's meta.json must be writeable because Jellyfin appends some information upon installing the plugin, like its active or disabled status. SHB automatically enables the plugin and deletes any plugin with the same prefix but other versions. Note that SHB does not attempt to find which version is latest. If twice the same plugin is added, the last one in the "plugins" list wins. ''; default = [ ]; type = types.listOf types.package; }; ldap = lib.mkOption { description = "LDAP configuration."; default = { }; type = types.submodule { options = { enable = lib.mkEnableOption "LDAP"; plugin = lib.mkOption { type = lib.types.package; description = "Pluging used for LDAP authentication."; default = shb.mkJellyfinPlugin (rec { pname = "jellyfin-plugin-ldapauth"; version = "22"; url = "https://github.com/jellyfin/${pname}/releases/download/v${version}/ldap-authentication_${version}.0.0.0.zip"; hash = "sha256-m2oD9woEuoSRiV9OeifAxZN7XQULMKS0Yq4TF+LjjpI="; }); }; host = lib.mkOption { type = types.str; description = "Host serving the LDAP server."; example = "127.0.0.1"; }; port = lib.mkOption { type = types.int; description = "Port where the LDAP server is listening."; example = 389; }; dcdomain = lib.mkOption { type = types.str; description = "DC domain for LDAP."; example = "dc=mydomain,dc=com"; }; userGroup = lib.mkOption { type = types.str; description = "LDAP user group"; default = "jellyfin_user"; }; adminGroup = lib.mkOption { type = types.str; description = "LDAP admin group"; default = "jellyfin_admin"; }; adminPassword = lib.mkOption { description = "LDAP admin password."; type = types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "jellyfin"; group = "jellyfin"; restartUnits = [ "jellyfin.service" ]; }; }; }; }; }; }; sso = lib.mkOption { description = "SSO configuration."; default = { }; type = types.submodule { options = { enable = lib.mkEnableOption "SSO"; plugin = lib.mkOption { type = lib.types.package; description = "Pluging used for SSO authentication."; default = shb.mkJellyfinPlugin (rec { pname = "jellyfin-plugin-sso"; version = "4.0.0.3"; url = "https://github.com/9p4/${pname}/releases/download/v${version}/sso-authentication_${version}.zip"; hash = "sha256-Jkuc+Ua7934iSutf/zTY1phTxaltUkfiujOkCi7BW8w="; }); }; provider = lib.mkOption { type = types.str; description = "OIDC provider name"; default = "Authelia"; }; endpoint = lib.mkOption { type = types.str; description = "OIDC endpoint for SSO"; example = "https://authelia.example.com"; }; clientID = lib.mkOption { type = types.str; description = "Client ID for the OIDC endpoint"; default = "jellyfin"; }; authorization_policy = lib.mkOption { type = types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; sharedSecret = lib.mkOption { description = "OIDC shared secret for Jellyfin."; type = types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "jellyfin"; group = "jellyfin"; restartUnits = [ "jellyfin.service" ]; }; }; }; sharedSecretForAuthelia = lib.mkOption { description = "OIDC shared secret for Authelia."; type = types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; ownerText = "config.shb.authelia.autheliaUser"; owner = config.shb.authelia.autheliaUser; }; }; }; }; }; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = types.submodule { options = shb.contracts.backup.mkRequester { user = "jellyfin"; sourceDirectories = [ config.services.jellyfin.dataDir ]; sourceDirectoriesText = '' [ "services.jellyfin.dataDir" ] ''; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${fqdn}"; externalUrlText = "https://\${config.shb.jellyfin.subdomain}.\${config.shb.jellyfin.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.port}"; }; }; }; }; imports = [ ../../lib/module.nix (lib.mkRenamedOptionModule [ "shb" "jellyfin" "adminPassword" ] [ "shb" "jellyfin" "admin" "password" ] ) # (lib.mkRenamedOptionModule # [ "shb" "jellyfin" "sso" "userGroup" ] # [ "shb" "jellyfin" "ldap" "userGroup" ] # ) # (lib.mkRenamedOptionModule # [ "shb" "jellyfin" "sso" "adminUserGroup" ] # [ "shb" "jellyfin" "ldap" "adminGroup" ] # ) ]; config = lib.mkIf cfg.enable { assertions = [ { assertion = (!cfg.ldap.enable && !cfg.sso.enable) -> cfg.admin != null; message = "Jellyfin admin user must be configured with shb.jellyfin.admin if LDAP or SSO integration are not configured."; } ]; services.jellyfin.enable = true; services.jellyfin.package = jellyfin; networking.firewall = { # from https://jellyfin.org/docs/general/networking/index.html, for auto-discovery allowedUDPPorts = [ 1900 7359 ]; }; services.nginx.enable = true; # Take advice from https://jellyfin.org/docs/general/networking/nginx/ and https://nixos.wiki/wiki/Plex services.nginx.virtualHosts."${fqdn}" = { forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; http2 = true; extraConfig = '' # The default `client_max_body_size` is 1M, this might not be enough for some posters, etc. client_max_body_size 20M; # Some players don't reopen a socket and playback stops totally instead of resuming after an extended pause send_timeout 100m; # use a variable to store the upstream proxy # in this example we are using a hostname which is resolved via DNS # (if you aren't using DNS remove the resolver line and change the variable to point to an IP address e.g `set $jellyfin 127.0.0.1`) set $jellyfin 127.0.0.1; # resolver 127.0.0.1 valid=30; #include /etc/letsencrypt/options-ssl-nginx.conf; #ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; #add_header Strict-Transport-Security "max-age=31536000" always; #ssl_trusted_certificate /etc/letsencrypt/live/DOMAIN_NAME/chain.pem; # Why this is important: https://blog.cloudflare.com/ocsp-stapling-how-cloudflare-just-made-ssl-30/ ssl_stapling on; ssl_stapling_verify on; # Security / XSS Mitigation Headers # NOTE: X-Frame-Options may cause issues with the webOS app add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "0"; # Do NOT enable. This is obsolete/dangerous add_header X-Content-Type-Options "nosniff"; # COOP/COEP. Disable if you use external plugins/images/assets add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Resource-Policy "same-origin" always; # Permissions policy. May cause issues on some clients add_header Permissions-Policy "accelerometer=(), ambient-light-sensor=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), display-capture=(), document-domain=(), encrypted-media=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), keyboard-map=(), local-fonts=(), magnetometer=(), microphone=(), payment=(), publickey-credentials-get=(), serial=(), sync-xhr=(), usb=(), xr-spatial-tracking=()" always; # Tell browsers to use per-origin process isolation add_header Origin-Agent-Cluster "?1" always; # Content Security Policy # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP # Enforces https content and restricts JS/CSS to origin # External Javascript (such as cast_sender.js for Chromecast) must be whitelisted. # NOTE: The default CSP headers may cause issues with the webOS app #add_header Content-Security-Policy "default-src https: data: blob: http://image.tmdb.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' https://www.gstatic.com/cv/js/sender/v1/cast_sender.js https://www.gstatic.com/eureka/clank/95/cast_sender.js https://www.gstatic.com/eureka/clank/96/cast_sender.js https://www.gstatic.com/eureka/clank/97/cast_sender.js https://www.youtube.com blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; frame-ancestors 'self'"; # From Plex: Plex has A LOT of javascript, xml and html. This helps a lot, but if it causes playback issues with devices turn it off. gzip on; gzip_vary on; gzip_min_length 1000; gzip_proxied any; gzip_types text/plain text/css text/xml application/xml text/javascript application/x-javascript image/svg+xml; gzip_disable "MSIE [1-6]\."; location = / { return 302 http://$host/web/; #return 302 https://$host/web/; } location / { # Proxy main Jellyfin traffic proxy_pass http://$jellyfin:${toString cfg.port}; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Host $http_host; # Disable buffering when the nginx proxy gets very resource heavy upon streaming proxy_buffering off; } # location block for /web - This is purely for aesthetics so /web/#!/ works instead of having to go to /web/index.html/#!/ location = /web/ { # Proxy main Jellyfin traffic proxy_pass http://$jellyfin:${toString cfg.port}/web/index.html; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Host $http_host; } location /socket { # Proxy Jellyfin Websockets traffic proxy_pass http://$jellyfin:${toString cfg.port}; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Protocol $scheme; proxy_set_header X-Forwarded-Host $http_host; } ''; }; services.prometheus.scrapeConfigs = [ { job_name = "jellyfin"; static_configs = [ { targets = [ "127.0.0.1:${toString cfg.port}" ]; labels = { "hostname" = config.networking.hostName; "domain" = cfg.domain; }; } ]; } ]; # LDAP config but you need to install the plugin by hand systemd.services.jellyfin.preStart = let ldapConfig = pkgs.writeText "LDAP-Auth.xml" '' ${cfg.ldap.host} ${builtins.toString cfg.ldap.port} false false false uid=admin,ou=people,${cfg.ldap.dcdomain} %SECRET_LDAP_PASSWORD% ou=people,${cfg.ldap.dcdomain} (memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}) ou=people,${cfg.ldap.dcdomain} (memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain}) false uid, cn, mail, displayName true false uid userPassword true ''; # SchemeOverride is needed because of # https://github.com/9p4/jellyfin-plugin-sso/issues/264 ssoConfig = pkgs.writeText "SSO-Auth.xml" '' ${cfg.sso.provider} https ${cfg.sso.endpoint} ${cfg.sso.clientID} %SECRET_SSO_SECRET% true true true ${cfg.ldap.adminGroup} ${cfg.ldap.userGroup} false groups groups true ''; brandingConfig = pkgs.writeText "branding.xml" '' <a href="https://${cfg.subdomain}.${cfg.domain}/SSO/OID/p/${cfg.sso.provider}" class="raised cancel block emby-button authentik-sso"> Sign in with ${cfg.sso.provider}&nbsp; <img alt="OpenID Connect (authentik)" title="OpenID Connect (authentik)" class="oauth-login-image" src="https://raw.githubusercontent.com/goauthentik/authentik/master/web/icons/icon.png"> </a> <a href="https://${cfg.subdomain}.${cfg.domain}/SSOViews/linking" class="raised cancel block emby-button authentik-sso"> Link ${cfg.sso.provider} config&nbsp; </a> <a href="${cfg.sso.endpoint}" class="raised cancel block emby-button authentik-sso"> ${cfg.sso.provider} config&nbsp; </a> /* Hide this in lieu of authentik link */ .emby-button.block.btnForgotPassword { display: none; } /* Make links look like buttons */ a.raised.emby-button { padding: 0.9em 1em; color: inherit !important; } /* Let disclaimer take full width */ .disclaimerContainer { display: block; } /* Optionally, apply some styling to the `.authentik-sso` class, probably let users configure this */ .authentik-sso { /* idk set a background image or something lol */ } .oauth-login-image { height: 24px; position: absolute; top: 12px; } true ''; debugLogging = pkgs.writeText "debugLogging.json" '' { "Serilog": { "MinimumLevel": { "Default": "Debug", "Override": { "": "Debug" } } } } ''; networkConfig = pkgs.writeText "" '' false false ${toString cfg.port} 8920 ${toString cfg.port} 8920 true false true false false true veth false false ''; in lib.strings.optionalString cfg.debug '' if [ -f "${config.services.jellyfin.configDir}/logging.json" ] && [ ! -L "${config.services.jellyfin.configDir}/logging.json" ]; then echo "A ${config.services.jellyfin.configDir}/logging.json file exists already, this indicates probably an existing installation. Please remove it before continuing." exit 1 fi ln -fs "${debugLogging}" "${config.services.jellyfin.configDir}/logging.json" '' + (shb.replaceSecretsScript { file = networkConfig; # Write permissions are needed otherwise the jellyfin-cli tool will not work correctly. permissions = "u=rw,g=rw,o="; resultPath = "${config.services.jellyfin.dataDir}/config/network.xml"; replacements = [ ]; }) + lib.strings.optionalString cfg.ldap.enable ( (shb.replaceSecretsScript { file = ldapConfig; resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/LDAP-Auth.xml"; replacements = [ { name = [ "LDAP_PASSWORD" ]; source = cfg.ldap.adminPassword.result.path; } ]; }) ) + lib.strings.optionalString cfg.sso.enable ( shb.replaceSecretsScript { file = ssoConfig; resultPath = "${config.services.jellyfin.dataDir}/plugins/configurations/SSO-Auth.xml"; replacements = [ { name = [ "SSO_SECRET" ]; source = cfg.sso.sharedSecret.result.path; } ]; } ) + lib.strings.optionalString cfg.sso.enable ( shb.replaceSecretsScript { file = brandingConfig; resultPath = "${config.services.jellyfin.dataDir}/config/branding.xml"; replacements = [ ]; } ) + ( let pluginInstallScript = p: '' pluginDir="${config.services.jellyfin.dataDir}/plugins/${pluginName p}" mkdir -p "$pluginDir" for f in "${p}"/*; do ln -sf "$f" "$pluginDir" done rm "$pluginDir/meta.json" ${pkgs.jq}/bin/jq ". + { status: \"Active\", autoUpdate: false, assemblies: [] }" "${p}/meta.json" > "$pluginDir/meta.json" echo "Disabling other versions of plugin ${pluginName p}" for p in "${config.services.jellyfin.dataDir}/plugins/${pluginNamePrefix p}"*; do if [ "$p" = "$pluginDir" ]; then continue fi echo "Marking plugin $p as disabled" ${pkgs.jq}/bin/jq ". + { status: \"Disabled\", }" "$p/meta.json" > "$p/meta.json.new" mv "$p/meta.json.new" "$p/meta.json" done ''; in lib.concatMapStringsSep "\n" pluginInstallScript cfg.plugins ); shb.jellyfin.plugins = lib.optionals cfg.ldap.enable [ cfg.ldap.plugin ] ++ lib.optionals cfg.sso.enable [ cfg.sso.plugin ]; systemd.tmpfiles.rules = lib.optionals cfg.ldap.enable [ "d '${config.services.jellyfin.dataDir}/plugins' 0750 jellyfin jellyfin - -" ]; systemd.services.jellyfin.serviceConfig.ExecStartPost = let # We must always wait for the service to be fully initialized, # even if we're planning on changing the config and restarting. # And the service is not initialized until this URL returns a 200 and not a 503. waitForCurl = pkgs.writeShellApplication { name = "waitForCurl"; runtimeInputs = [ pkgs.curl ]; text = '' URL="http://127.0.0.1:${toString cfg.port}/System/Info/Public" SLEEP_INTERVAL_SEC=2 TIMEOUT=60 start_time=$(date +%s) echo "Waiting for $URL to return HTTP 200..." while true; do status_code=$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true) if [ "$status_code" = "200" ]; then echo "Service is up (HTTP 200 received)." exit 0 fi now=$(date +%s) elapsed=$(( now - start_time )) if [ $elapsed -ge $TIMEOUT ]; then echo "Timeout reached ($TIMEOUT seconds). Exiting with failure." exit 1 fi echo "Waiting for service... (status: $status_code), elapsed: ''${elapsed}s" sleep "$SLEEP_INTERVAL_SEC" done echo "Finished waiting, curl returned a 200." ''; }; # This file is used to know if the jellyfin service has been restarted # because a new config just got written to. # # If the file does not exist, write the config, create the file then restart. # If the file exists, do nothing and remove the file, resetting the state for the next time. restartedFile = "${config.services.jellyfin.dataDir}/shb-jellyfin-restarted"; writeConfig = pkgs.writeShellApplication { name = "writeConfig"; runtimeInputs = [ pkgs.systemd ]; text = '' if ! [ -f "${restartedFile}" ]; then ${lib.getExe config.services.jellyfin.package} config \ --datadir='${config.services.jellyfin.dataDir}' \ --configdir='${config.services.jellyfin.configDir}' \ --cachedir='${config.services.jellyfin.cacheDir}' \ --logdir='${config.services.jellyfin.logDir}' \ --username=${cfg.admin.username} \ --password-file=${cfg.admin.password.result.path} \ --enable-remote-access=true \ --write fi ''; }; restartJellyfinOnce = pkgs.writeShellApplication { name = "restartJellyfin"; runtimeInputs = [ pkgs.systemd ]; text = '' if [ -f "${restartedFile}" ]; then echo "jellyfin.service has been restarted" rm "${restartedFile}" else echo "Restarting jellyfin.service" echo "This file is used by SelfHostBlocks to know when to restart jellyfin" > "${restartedFile}" systemctl reload-or-restart jellyfin.service fi ''; }; in lib.optionals (cfg.admin != null) [ (lib.getExe waitForCurl) (lib.getExe writeConfig) # The '+' is to get elevated privileges to be able to restart the service. "+${lib.getExe restartJellyfinOnce}" ]; systemd.services.jellyfin.serviceConfig.TimeoutStartSec = 300; shb.authelia.oidcClients = lib.optionals cfg.sso.enable [ { client_id = cfg.sso.clientID; client_name = "Jellyfin"; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; public = false; authorization_policy = cfg.sso.authorization_policy; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}" "https://${cfg.subdomain}.${cfg.domain}/sso/OID/redirect/${cfg.sso.provider}" ]; require_pkce = true; pkce_challenge_method = "S256"; userinfo_signed_response_alg = "none"; # Jellyfin SSO plugin uses client_secret_post for token exchange token_endpoint_auth_method = "client_secret_post"; # Required OIDC scopes for Authelia to return group claims scopes = [ "openid" "profile" "email" "groups" ]; } ]; }; } ================================================ FILE: modules/services/karakeep/docs/default.md ================================================ # Karakeep {#services-karakeep} Defined in [`/modules/blocks/karakeep.nix`](@REPO@/modules/blocks/karakeep.nix), found in the `selfhostblocks.nixosModules.karakeep` module. See [the manual](usage.html#usage-flake) for how to import the module in your code. This service sets up [Karakeep][] which is a bookmarking service powered by LLMs. It integrates well with [Ollama][]. [Karakeep]: https://github.com/karakeep-app/karakeep [Ollama]: https://ollama.com/ ## Features {#services-karakeep-features} - Declarative [LDAP](#services-karakeep-options-shb.karakeep.ldap) Configuration. - Needed LDAP groups are created automatically. - Declarative [SSO](#services-karakeep-options-shb.karakeep.sso) Configuration. - When SSO is enabled, login with user and password is disabled. - Registration is enabled through SSO. - Meilisearch configured with production environment and master key. - Access through [subdomain](#services-karakeep-options-shb.karakeep.subdomain) using reverse proxy. - Access through [HTTPS](#services-karakeep-options-shb.karakeep.ssl) using reverse proxy. - [Backup](#services-karakeep-options-shb.karakeep.sso) through the [backup block](./blocks-backup.html). - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-karakeep-usage-applicationdashboard) ## Usage {#services-karakeep-usage} ### Initial Configuration {#services-karakeep-usage-configuration} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ```nix { shb.karakeep = { enable = true; domain = "example.com"; subdomain = "karakeep"; ssl = config.shb.certs.certs.letsencrypt.${domain}; nextauthSecret.result = config.shb.sops.secret.nextauthSecret.result; sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; sharedSecret.result = config.shb.sops.secret.oidcSecret.result; sharedSecretForAuthelia.result = config.shb.sops.secret.oidcAutheliaSecret.result; }; }; shb.sops.secret.nextauthSecret.request = config.shb.karakeep.nextauthSecret.request; shb.sops.secret."karakeep/oidcSecret".request = config.shb.karakeep.sso.sharedSecret.request; shb.sops.secret."karakeep/oidcAutheliaSecret" = { request = config.shb.karakeep.sso.sharedSecretForAuthelia.request; settings.key = "karakeep/oidcSecret"; }; } ``` Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. The [user](#services-open-webui-options-shb.open-webui.ldap.userGroup) and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup) LDAP groups are created automatically. ### Application Dashboard {#services-karakeep-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-karakeep-options-shb.karakeep.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Documents.services.Karakeep = { sortOrder = 3; dashboard.request = config.shb.karakeep.dashboard.request; }; } ``` An API key can be set to show extra info: ```nix { shb.homepage.servicesGroups.Documents.services.Karakeep = { apiKey.result = config.shb.sops.secret."karakeep/homepageApiKey".result; }; shb.sops.secret."karakeep/homepageApiKey".request = config.shb.homepage.servicesGroups.Documents.services.Karakeep.apiKey.request; } ``` ## Integration with Ollama {#services-karakeep-ollama} Assuming ollama is enabled, it will be available on port `config.services.ollama.port`. The following snippet sets up acceleration using an AMD (i)GPU and loads some models. ```nix { services.ollama = { enable = true; # https://wiki.nixos.org/wiki/Ollama#AMD_GPU_with_open_source_driver acceleration = "rocm"; # https://ollama.com/library loadModels = [ "deepseek-r1:1.5b" "llama3.2:3b" "llava:7b" "mxbai-embed-large:335m" "nomic-embed-text:v1.5" ]; }; } ``` Integrating with the ollama service is done with: ```nix { services.open-webui = { environment.OLLAMA_BASE_URL = "http://127.0.0.1:${toString config.services.ollama.port}"; }; } ``` Notice we're using the upstream service here `services.open-webui`, not `shb.open-webui`. ## Options Reference {#services-karakeep-options} ```{=include=} options id-prefix: services-karakeep-options- list-id: selfhostblocks-services-karakeep-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/karakeep.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.karakeep; in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ]; options.shb.karakeep = { enable = lib.mkEnableOption "the Karakeep service"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Karakeep will be served."; default = "karakeep"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which Karakeep will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; port = lib.mkOption { type = lib.types.port; description = "Port Karakeep listens to incoming requests."; default = 3000; }; environment = lib.mkOption { default = { }; type = lib.types.attrsOf lib.types.str; description = "Extra environment variables. See https://docs.karakeep.app/configuration/"; example = '' { OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}"; INFERENCE_TEXT_MODEL = "deepseek-r1:1.5b"; INFERENCE_IMAGE_MODEL = "llava"; EMBEDDING_TEXT_MODEL = "nomic-embed-text:v1.5"; INFERENCE_ENABLE_AUTO_SUMMARIZATION = "true"; INFERENCE_JOB_TIMEOUT_SEC = "200"; } ''; }; ldap = lib.mkOption { description = '' Setup LDAP integration. ''; default = { }; type = lib.types.submodule { options = { userGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to login."; default = "karakeep_user"; }; }; }; }; sso = lib.mkOption { description = '' Setup SSO integration. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "SSO integration."; authEndpoint = lib.mkOption { type = lib.types.str; description = "Endpoint to the SSO provider."; example = "https://authelia.example.com"; }; clientID = lib.mkOption { type = lib.types.str; description = "Client ID for the OIDC endpoint."; default = "karakeep"; }; authorization_policy = lib.mkOption { type = lib.types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; sharedSecret = lib.mkOption { description = "OIDC shared secret for Karakeep."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "karakeep"; # These services are the ones relying on the environment file containing the secrets. restartUnits = [ "karakeep-init.service" "karakeep-workers.service" "karakeep-workers.service" ]; }; }; }; sharedSecretForAuthelia = lib.mkOption { description = "OIDC shared secret for Authelia. Must be the same as `sharedSecret`"; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; ownerText = "config.shb.authelia.autheliaUser"; owner = config.shb.authelia.autheliaUser; }; }; }; }; }; }; backup = lib.mkOption { description = '' Backup state directory. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "karakeep"; sourceDirectories = [ "/var/lib/karakeep" ]; }; }; }; nextauthSecret = lib.mkOption { description = "NextAuth secret."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "karakeep"; # These services are the ones relying on the environment file containing the secrets. restartUnits = [ "karakeep-init.service" "karakeep-workers.service" "karakeep-workers.service" ]; }; }; }; meilisearchMasterKey = lib.mkOption { description = "Master key used to secure communication with Meilisearch."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "karakeep"; # These services are the ones relying on the environment file containing the secrets. restartUnits = [ "karakeep-init.service" "karakeep-workers.service" "karakeep-workers.service" ]; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.karakeep.subdomain}.\${config.shb.karakeep.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.port}"; }; }; }; }; config = ( lib.mkMerge [ (lib.mkIf cfg.enable { services.karakeep = { enable = true; meilisearch.enable = true; extraEnvironment = { PORT = toString cfg.port; DISABLE_NEW_RELEASE_CHECK = "true"; # These are handled by NixOS } // cfg.environment; }; shb.nginx.vhosts = [ { inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString cfg.port}/"; } ]; # Piggybacking onto the upstream karakeep-init and replacing its script by ours. # This is needed otherwise the MEILI_MASTER_KEY is generated randomly on first start # instead of using the value from the cfg.meilisearchMasterKey option. systemd.services.karakeep-init = { script = lib.mkForce ( (shb.replaceSecrets { userConfig = { MEILI_MASTER_KEY.source = cfg.meilisearchMasterKey.result.path; NEXTAUTH_SECRET.source = cfg.nextauthSecret.result.path; } // lib.optionalAttrs cfg.sso.enable { OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path; }; resultPath = "/var/lib/karakeep/settings.env"; generator = shb.toEnvVar; }) + '' export DATA_DIR="$STATE_DIRECTORY" exec ${config.services.karakeep.package}/lib/karakeep/migrate '' ); }; }) (lib.mkIf cfg.enable { services.meilisearch = { masterKeyFile = cfg.meilisearchMasterKey.result.path; settings = { experimental_dumpless_upgrade = true; env = "production"; }; }; }) (lib.mkIf (cfg.enable && cfg.sso.enable) { shb.lldap.ensureGroups = { ${cfg.ldap.userGroup} = { }; }; shb.authelia.extraOidcAuthorizationPolicies.karakeep = { default_policy = "deny"; rules = [ { subject = [ "group:${cfg.ldap.userGroup}" ]; policy = cfg.sso.authorization_policy; } ]; }; shb.authelia.oidcClients = [ { client_id = cfg.sso.clientID; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; scopes = [ "openid" "email" "profile" ]; authorization_policy = "karakeep"; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/api/auth/callback/custom" ]; } ]; services.karakeep = { extraEnvironment = { DISABLE_SIGNUPS = "false"; DISABLE_PASSWORD_AUTH = "true"; NEXTAUTH_URL = "https://${cfg.subdomain}.${cfg.domain}"; OAUTH_WELLKNOWN_URL = "${cfg.sso.authEndpoint}/.well-known/openid-configuration"; OAUTH_PROVIDER_NAME = "Single Sign-On"; OAUTH_CLIENT_ID = cfg.sso.clientID; OAUTH_SCOPE = "openid email profile"; }; }; }) ] ); } ================================================ FILE: modules/services/mailserver/docs/default.md ================================================ # Mailserver Service {#services-mailserver} Defined in [`/modules/services/mailserver.nix`](@REPO@/modules/services/mailserver.nix). This NixOS module is a service that sets up the [NixOS Simple Mailserver](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver) project. It integrates the upstream project with the SHB modules like the SSL module, the contract for secrets and the LLDAP module. It also exposes an XML file which allows some email clients to auto configure themselves. Setting up a self-hosted email server in this age can be quite time consuming because you need to maintain a good IP hygiene to avoid being marked as spam from the big players. To avoid needing to deal with this, this module provides the means to use an email provider (like Fastmail or ProtonMail) as a mere proxy. If you also setup the email provider using your own custom domain, this combination allows you to change email provider without needing to change your clients or notify your email correspondents and keep a backup of all your emails at the same time. The setup looks like so: ``` Domain --[ DNS records ]-> Email Provider --[ mbsync ]-> SHB Server Internet <---------------- Email Provider <-[ postfix ]-- SHB Server ``` Configuring your domain name to point to your email provider is out of scope here. See the documentation for "custom domain" for you email provider, like for [Fastmail](https://www.fastmail.com/features/domains/) and [ProtonMail](https://proton.me/support/custom-domain) To use an email provider as a proxy, use the [shb.mailserver.imapSync](#services-mailserver-options-shb.mailserver.imapSync) and [shb.mailserver.smtpRelay](#services-mailserver-options-shb.mailserver.smtpRelay), options. ## Usage {#services-mailserver-usage} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). ```nix let domain = "example.com"; username = "me@example.com"; in { imports = [ selfhostblocks.nixosModules.mailserver ]; shb.mailserver = { enable = true; inherit domain; subdomain = "imap"; ssl = config.shb.certs.certs.letsencrypt."domain"; imapSync = { syncTimer = "10s"; accounts.fastmail = { host = "imap.fastmail.com"; port = 993; inherit username; password.result = config.shb.sops.secret."mailserver/imap/fastmail/password".result; mapSpecialJunk = "Spam"; }; }; smtpRelay = { host = "smtp.fastmail.com"; port = 587; inherit username; password.result = config.shb.sops.secret."mailserver/smtp/fastmail/password".result; }; ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminName = "admin"; adminPassword.result = config.shb.sops.secret."mailserver/ldap_admin_password".result; account = "fastmail"; }; }; # Optionally add some mailboxes mailserver.mailboxes = { Drafts = { auto = "subscribe"; specialUse = "Drafts"; }; Junk = { auto = "subscribe"; specialUse = "Junk"; }; Sent = { auto = "subscribe"; specialUse = "Sent"; }; Trash = { auto = "subscribe"; specialUse = "Trash"; }; Archive = { auto = "subscribe"; specialUse = "Archive"; }; }; shb.sops.secret."mailserver/smtp/fastmail/password".request = config.shb.mailserver.smtpRelay.password.request; shb.sops.secret."mailserver/imap/fastmail/password".request = config.shb.mailserver.imapSync.accounts.fastmail.password.request; shb.sops.secret."mailserver/ldap_admin_password" = { request = config.shb.mailserver.ldap.adminPassword.request; # This reuses the admin password set in the shb.lldap module. settings.key = "lldap/user_password"; }; } ``` ### Secrets {#services-mailserver-usage-secrets} Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. ### LDAP {#services-mailserver-usage-ldap} The [user](#services-mailserver-options-shb.mailserver.ldap.userGroup) LDAP group is created automatically. ### Disk Layout {#services-mailserver-usage-disk-layout} The disk layout has been purposely set to use slashes `/` for subfolders. By experience, this works better with iOS mail. ### Backup {#services-mailserver-usage-backup} Backing up your emails using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."mailserver" = { request = config.shb.mailserver.backup; settings = { enable = true; }; }; ``` The name `"mailserver"` in the `instances` can be anything. The `config.shb.mailserver.backup` option provides what directories to backup. You can define any number of Restic instances to backup your emails multiple times. You will then need to configure more options like the `repository`, as explained in the [restic](blocks-restic.html) documentation. ### Certificates {#services-mailserver-certs} For Let's Encrypt certificates, add: ```nix let domain = "example.com"; in { shb.certs.certs.letsencrypt.${domain}.extraDomains = [ "${config.shb.mailserver.subdomain}.${config.shb.mailserver.domain}" ]; } ``` ### Impermanence {#services-mailserver-impermanence} To save the data folder in an impermanence setup, add: ```nix { shb.zfs.datasets."safe/mailserver/index".path = config.shb.mailserver.impermanence.index; shb.zfs.datasets."safe/mailserver/mail".path = config.shb.mailserver.impermanence.mail; shb.zfs.datasets."safe/mailserver/sieve".path = config.shb.mailserver.impermanence.sieve; shb.zfs.datasets."safe/mailserver/dkim".path = config.shb.mailserver.impermanence.dkim; } ``` ### Declarative LDAP {#services-mailserver-declarative-ldap} To add a user `USERNAME` to the user group, add: ```nix shb.lldap.ensureUsers.USERNAME.groups = [ config.shb.mailserver.ldap.userGroup ]; ``` ### Application Dashboard {#services-mailserver-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-mailserver-options-shb.mailserver.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Home.services.Mailserver = { sortOrder = 1; dashboard.request = config.shb.mailserver.dashboard.request; }; } ``` ## Debug {#services-mailserver-debug} Debugging this will be certainly necessary. The first issue you will encounter will probably be with `mbsync` under the [shb.mailserver.imapSync](#services-mailserver-options-shb.mailserver.imapSync) option with the folder name mapping. ### Systemd Services {#services-mailserver-debug-systemd} The 3 systemd services setup by this module are: - `mbsync.service` - `dovecot.service` - `postfix.service` ### Folders {#services-mailserver-debug-folders} The 4 folders where state is stored are: - `config.mailserver.indexDir` = `/var/lib/dovecot/indices` - `config.mailserver.mailDirectory` = `/var/vmail` - `config.mailserver.sieveDirectory` = `/var/sieve` - `config.mailserver.dkimKeyDirectory` = `/var/dkim` ### Open Ports {#services-mailserver-debug-ports} The ports opened by default in this module are: - Submissions: 465 - Imap: 993 You will need to forward those ports on your router if you want to access to your emails from the internet. The complete list can be found in the [upstream repository](https://gitlab.com/simple-nixos-mailserver/nixos-mailserver/-/blob/5965fae920b6b97f39f94bdb6195631e274c93a5/mail-server/networking.nix). ### List Email Provider Folder Mapping {#services-mailserver-debug-folder-mapping} Replace `$USER` and `$PASSWORD` by those used to connect to your email provider. Yes, you will need to enter verbatim `a LOGIN ...` and `b LIST "" "*"`. ``` $ nix run nixpkgs#openssl -- s_client -connect imap.fastmail.com:993 -crlf -quiet a LOGIN $USER $password b LIST "" "*" ``` Example output will be: ``` * LIST (\HasNoChildren) "/" INBOX * LIST (\HasNoChildren \Drafts) "/" Drafts * LIST (\HasNoChildren \Sent) "/" Sent * LIST (\Noinferiors \HasNoChildren \Junk) "/" Spam ... ``` Here you can see the special folder `\Junk` is actually named `Spam`. To handle this, set the `.mapSpecial*` options: ``` { shb.mailserver.imapSync.accounts. = { mapSpecialJunk = "Spam"; }; } ``` ### List Local Folders {#services-mailserver-debug-local-folders} Check the local folders to make sure the mapping is correct and all folders are correctly downloaded. For example, if the mapping above is wrong, you will see both a `Junk` and `Spam` folder while if it is correct, you will only see the `Junk` folder. ``` $ sudo doveadm mailbox list -u $USER Junk Trash Drafts Sent INBOX MyCustomFolder ``` The following command shows the number of messages in a folder: ``` $ sudo doveadm mailbox status -u $USER messages INBOX INBOX messages=13591 ``` If any folder is not appearing or has 0 message but should have some, it could mean dovecot is not setup correctly and assumes an incorrect folder layout. If that is the case, check the user config with: ``` $ sudo doveadm user $USER field value uid 5000 gid 5000 home /var/vmail/fastmail/$USER mail maildir:~/mail:LAYOUT=fs virtualMail ``` ### Test Auth {#services-mailserver-debug-auth} To test authentication to your dovecot instance, run: ``` $ nix run nixpkgs#openssl -- s_client -connect $SUBDOMAIN.$DOMAIN:993 -crlf -quiet . LOGIN $USER $PASSWORD ``` You must here also enter the second line verbatim, replacing your user and password with the real one. On success, you will see: ``` . OK [CAPABILITY IMAP4rev1 ...] Logged in ``` Otherwise, either if the password is wrong or, when using LDAP if the user is not part of the LDAP group, you will see: ``` . NO [AUTHENTICATIONFAILED] Authentication failed. ``` To test the postfix instance, run: ``` $ swaks \ --server $SUBDOMAIN.$DOMAIN \ --port 465 \ --tls-on-connect \ --auth LOGIN \ --auth-user $USER \ --auth-password '$PASSWORD' \ --from $USER \ --to $USER ``` Try once with a wrong password and once with a correct one. The former should log: ``` <~* 535 5.7.8 Error: authentication failed: (reason unavailable) ``` ## Mobile Apps {#services-mailserver-mobile} This module was tested with: - the iOS mail mobile app, - Thunderbird on NixOS. The iOS mail app is pretty finicky. If downloading emails does not work, make sure the certificate used includes the whole chain: ```bash $ openssl s_client -connect $SUBDOMAIN.$DOMAIN:993 -showcerts ``` Normally, the other options are setup correctly but if it fails for you, feel free to open an issue. ## Options Reference {#services-mailserver-options} ```{=include=} options id-prefix: services-mailserver-options- list-id: selfhostblocks-service-mailserver-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/mailserver.nix ================================================ { config, lib, shb, pkgs, ... }: let cfg = config.shb.mailserver; in { imports = [ ( builtins.fetchGit { url = "https://gitlab.com/simple-nixos-mailserver/nixos-mailserver.git"; ref = "master"; rev = "7d433bf89882f61621f95082e90a4ab91eb0bdd3"; } + "/default.nix" ) ../blocks/lldap.nix ]; options.shb.mailserver = { enable = lib.mkEnableOption "SHB's nixos-mailserver module"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which imap and smtp functions will be served."; default = "imap"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which imap and smtp functions will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; adminUsername = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Admin username. postmaster will be made an alias of this user. ''; example = "admin"; }; adminPassword = lib.mkOption { description = "Admin user password."; default = null; type = lib.types.nullOr ( lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.services.postfix.user; ownerText = "services.postfix.user"; restartUnits = [ "dovecot.service" ]; }; } ); }; imapSync = lib.mkOption { description = '' Synchronize one or more email providers through IMAP to your dovecot instance. This allows you to backup that email provider and centralize your accounts in this dovecot instance. ''; default = null; type = lib.types.nullOr ( lib.types.submodule { options = { syncTimer = lib.mkOption { type = lib.types.str; default = "5m"; description = '' Systemd timer for when imap sync job should happen. This timer is not scheduling the job at regular intervals. After a job finishes, the given amount of time is waited then the next job is started. The default is set deliberatily slow to not spam you when setting up your mailserver. When everything works, you will want to reduce it to 10s or something like that. ''; example = "10s"; }; debug = lib.mkOption { type = lib.types.bool; default = false; description = "Enable verbose mbsync logging."; }; accounts = lib.mkOption { description = '' Accounts to sync emails from using IMAP. Emails will be stored under `''${config.mailserver.mailDirectory}/''${name}/''${username}` ''; type = lib.types.attrsOf ( lib.types.submodule { options = { host = lib.mkOption { type = lib.types.str; description = "Hostname of the email's provider IMAP server."; example = "imap.fastmail.com"; }; port = lib.mkOption { type = lib.types.port; description = "Port of the email's provider IMAP server."; default = 993; }; username = lib.mkOption { type = lib.types.str; description = "Username used to login to the email's provider IMAP server."; example = "userA@fastmail.com"; }; password = lib.mkOption { description = '' Password used to login to the email's provider IMAP server. The password could be an "app password" like for [Fastmail](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) ''; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.mailserver.vmailUserName; restartUnits = [ "mbsync.service" ]; }; }; }; sslType = lib.mkOption { description = "Connection security method."; type = lib.types.enum [ "IMAPS" "STARTTLS" ]; default = "IMAPS"; }; timeout = lib.mkOption { description = "Connect and data timeout."; type = lib.types.int; default = 120; }; mapSpecialDrafts = lib.mkOption { type = lib.types.str; default = "Drafts"; description = '' Drafts special folder name on far side. You only need to change this if mbsync logs the following error: Error: ... far side box Drafts cannot be opened ''; }; mapSpecialSent = lib.mkOption { type = lib.types.str; default = "Sent"; description = '' Sent special folder name on far side. You only need to change this if mbsync logs the following error: Error: ... far side box Sent cannot be opened ''; }; mapSpecialTrash = lib.mkOption { type = lib.types.str; default = "Trash"; description = '' Trash special folder name on far side. You only need to change this if mbsync logs the following error: Error: ... far side box Trash cannot be opened ''; }; mapSpecialJunk = lib.mkOption { type = lib.types.str; default = "Junk"; description = '' Junk special folder name on far side. You only need to change this if mbsync logs the following error: Error: ... far side box Junk cannot be opened ''; example = "Spam"; }; }; } ); }; }; } ); }; smtpRelay = lib.mkOption { description = '' Proxy outgoing emails through an email provider. In short, this can help you avoid having your outgoing emails marked as spam. See the manual for a lengthier explanation. ''; default = null; type = lib.types.nullOr ( lib.types.submodule { options = { host = lib.mkOption { type = lib.types.str; description = "Hostname of the email's provider SMTP server."; example = "smtp.fastmail.com"; }; port = lib.mkOption { type = lib.types.port; description = "Port of the email's provider SMTP server."; default = 587; }; username = lib.mkOption { description = "Username used to login to the email's provider SMTP server."; type = lib.types.str; }; password = lib.mkOption { description = '' Password used to login to the email's provider IMAP server. The password could be an "app password" like for [Fastmail](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) ''; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = config.services.postfix.user; ownerText = "services.postfix.user"; restartUnits = [ "postfix.service" ]; }; }; }; }; } ); }; ldap = lib.mkOption { description = '' LDAP Integration. Enabling this app will create a new LDAP configuration or update one that exists with the given host. ''; default = { }; type = lib.types.nullOr ( lib.types.submodule { options = { enable = lib.mkEnableOption "LDAP app."; host = lib.mkOption { type = lib.types.str; description = '' Host serving the LDAP server. ''; default = "127.0.0.1"; }; port = lib.mkOption { type = lib.types.port; description = '' Port of the service serving the LDAP server. ''; default = 389; }; dcdomain = lib.mkOption { type = lib.types.str; description = "dc domain for ldap."; example = "dc=mydomain,dc=com"; }; account = lib.mkOption { type = lib.types.str; description = '' Select one account from those defined in `shb.mailserver.imapSync.accounts` to login with. Using LDAP, you can only connect to one account. This limitation could maybe be lifted, feel free to post an issue if you need this. ''; }; adminName = lib.mkOption { type = lib.types.str; description = "Admin user of the LDAP server."; default = "admin"; }; adminPassword = lib.mkOption { description = "LDAP server admin password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "nextcloud"; restartUnits = [ "dovecot.service" ]; }; }; }; userGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to use mails."; default = "mail_user"; }; }; } ); }; backup = lib.mkOption { description = '' Backup emails, index and sieve. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = config.mailserver.vmailUserName; sourceDirectories = builtins.filter (x: x != null) [ config.mailserver.indexDir config.mailserver.mailDirectory config.mailserver.sieveDirectory ]; sourceDirectoriesText = '' [ config.mailserver.indexDir config.mailserver.mailDirectory config.mailserver.sieveDirectory ] ''; }; }; }; backupDKIM = lib.mkOption { description = '' Backup dkim directory. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = config.services.rspamd.user; userText = "services.rspamd.user"; sourceDirectories = builtins.filter (x: x != null) [ config.mailserver.dkimKeyDirectory ]; sourceDirectoriesText = '' [ config.mailserver.dkimKeyDirectory ] ''; }; }; }; impermanence = lib.mkOption { description = '' Path to save when using impermanence setup. ''; type = lib.types.attrsOf lib.types.str; default = { index = config.mailserver.indexDir; mail = config.mailserver.mailDirectory; sieve = config.mailserver.sieveDirectory; dkim = config.mailserver.dkimKeyDirectory; }; defaultText = lib.literalExpression '' { index = config.mailserver.indexDir; mail = config.mailserver.mailDirectory; sieve = config.mailserver.sieveDirectory; dkim = config.mailserver.dkimKeyDirectory; } ''; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.mailserver.subdomain}.\${config.shb.mailserver.domain}"; }; }; }; }; config = lib.mkMerge [ (lib.mkIf cfg.enable { mailserver = { enable = true; stateVersion = 3; fqdn = "${cfg.subdomain}.${cfg.domain}"; domains = [ cfg.domain ]; localDnsResolver = false; enableImapSsl = true; enableSubmissionSsl = true; x509 = { certificateFile = cfg.ssl.paths.cert; privateKeyFile = cfg.ssl.paths.key; }; # Using / is needed for iOS mail. # Both following options are used to organize subfolders in subdirectories. hierarchySeparator = "/"; useFsLayout = true; }; services.postfix.config = { smtpd_tls_security_level = lib.mkForce "encrypt"; }; # Is probably needed for iOS mail. services.dovecot2.extraConfig = '' ssl_min_protocol = TLSv1.2 ssl_cipher_list = HIGH:!aNULL:!MD5 ''; services.nginx = { enable = true; virtualHosts."${cfg.domain}" = let announce = pkgs.writeTextDir "config-v1.1.xml" '' ${cfg.domain} ${cfg.domain} Mailserver ${cfg.subdomain}.${cfg.domain} 993 SSL password-cleartext %EMAILADDRESS% ${cfg.subdomain}.${cfg.domain} 465 SSL password-cleartext %EMAILADDRESS% ''; in { forceSSL = true; # Redirect HTTP → HTTPS root = "/var/www"; # Dummy root locations."/.well-known/autoconfig/mail/" = { alias = "${announce}/"; extraConfig = '' default_type application/xml; ''; }; }; virtualHosts."${cfg.subdomain}.${cfg.domain}" = let landingPage = pkgs.writeTextDir "index.html" ''

Configuration of the mailserver is done automatically thanks to ${cfg.domain}/.well-known/autoconfig/mail/config-v1.1.xml.

''; in { forceSSL = true; # Redirect HTTP → HTTPS root = "/var/www"; # Dummy root locations."/" = { alias = "${landingPage}/"; extraConfig = '' default_type application/html; ''; }; }; }; }) (lib.mkIf (cfg.enable && cfg.adminUsername != null) { assertions = [ { assertion = cfg.adminPassword != null; message = "`shb.mailserver.adminPassword` must be not null if `shb.mailserver.adminUsername` is not null."; } ]; mailserver = { # To create the password hashes, use: # nix run nixpkgs#mkpasswd -- --run 'mkpasswd -s' loginAccounts = { "${cfg.adminUsername}@${cfg.domain}" = { hashedPasswordFile = cfg.adminPassword.result.path; aliases = [ "postmaster@${cfg.domain}" ]; }; }; }; }) (lib.mkIf (cfg.enable && cfg.ldap != null) { assertions = [ { assertion = cfg.adminUsername == null; message = "`shb.mailserver.adminUsername` must be null `shb.mailserver.ldap` integration is set."; } ]; shb.lldap.ensureGroups = { ${cfg.ldap.userGroup} = { }; }; mailserver = { ldap = { enable = true; uris = [ "ldap://${cfg.ldap.host}:${toString cfg.ldap.port}" ]; searchBase = "ou=people,${cfg.ldap.dcdomain}"; searchScope = "sub"; bind = { dn = "uid=${cfg.ldap.adminName},ou=people,${cfg.ldap.dcdomain}"; passwordFile = cfg.ldap.adminPassword.result.path; }; # Note that nixos simple mailserver sets auth_bind=yes # which means authentication binds are used. # https://doc.dovecot.org/2.3/configuration_manual/authentication/ldap_bind/#authentication-ldap-bind dovecot = let filter = "(&(objectClass=inetOrgPerson)(mail=%{user})(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))"; in { passAttrs = "user=user"; passFilter = filter; userAttrs = lib.concatStringsSep "," [ "=home=${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u" # "mail=maildir:${config.mailserver.mailDirectory}/${cfg.ldap.account}/%u/mail" "uid=${config.mailserver.vmailUserName}" "gid=${config.mailserver.vmailGroupName}" ]; userFilter = filter; }; postfix = { filter = "(&(objectClass=inetOrgPerson)(mail=%s)(memberOf=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}))"; mailAttribute = "mail"; uidAttribute = "mail"; }; }; }; }) (lib.mkIf (cfg.enable && cfg.imapSync != null) { systemd.services.mbsync = let configFile = let mkAccount = name: acct: '' # ${name} account IMAPAccount ${name} Host ${acct.host} Port ${toString acct.port} User ${acct.username} PassCmd "cat ${acct.password.result.path}" TLSType ${acct.sslType} AuthMechs LOGIN Timeout ${toString acct.timeout} IMAPStore ${name}-remote Account ${name} MaildirStore ${name}-local INBOX ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/ # Maps subfolders on far side to actual subfolders on disk. # The other option is Maildir++ but then the mailserver.hierarchySeparator must be set to a dot '.' SubFolders Verbatim Path ${config.mailserver.mailDirectory}/${name}/${acct.username}/mail/ Channel ${name}-main Far :${name}-remote: Near :${name}-local: Patterns * !Drafts !Sent !Trash !Junk !${acct.mapSpecialDrafts} !${acct.mapSpecialSent} !${acct.mapSpecialTrash} !${acct.mapSpecialJunk} Create Both Expunge Both SyncState * Sync All CopyArrivalDate yes # Preserve date from incoming message. Channel ${name}-drafts Far :${name}-remote:"${acct.mapSpecialDrafts}" Near :${name}-local:"Drafts" Create Both Expunge Both SyncState * Sync All CopyArrivalDate yes # Preserve date from incoming message. Channel ${name}-sent Far :${name}-remote:"${acct.mapSpecialSent}" Near :${name}-local:"Sent" Create Both Expunge Both SyncState * Sync All CopyArrivalDate yes # Preserve date from incoming message. Channel ${name}-trash Far :${name}-remote:"${acct.mapSpecialTrash}" Near :${name}-local:"Trash" Create Both Expunge Both SyncState * Sync All CopyArrivalDate yes # Preserve date from incoming message. Channel ${name}-junk Far :${name}-remote:"${acct.mapSpecialJunk}" Near :${name}-local:"Junk" Create Both Expunge Both SyncState * Sync All CopyArrivalDate yes # Preserve date from incoming message. Group ${name} Channel ${name}-main Channel ${name}-drafts Channel ${name}-sent Channel ${name}-trash Channel ${name}-junk # END ${name} account ''; in pkgs.writeText "mbsync.conf" ( lib.concatStringsSep "\n" (lib.mapAttrsToList mkAccount cfg.imapSync.accounts) ); in { description = "Sync mailbox"; serviceConfig = { Type = "oneshot"; User = config.mailserver.vmailUserName; }; script = let debug = if cfg.imapSync.debug then "-V" else ""; in '' ${pkgs.isync}/bin/mbsync --all ${debug} --config ${configFile} ''; }; systemd.tmpfiles.rules = let mkAccount = name: acct: # The equal sign makes sure parent directories have the corret user and group too. [ "d '${config.mailserver.mailDirectory}/${name}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -" "d '${config.mailserver.mailDirectory}/${name}/${acct.username}' 0750 ${config.mailserver.vmailUserName} ${config.mailserver.vmailGroupName} - -" ]; in lib.flatten (lib.mapAttrsToList mkAccount cfg.imapSync.accounts); systemd.timers.mbsync = { wantedBy = [ "timers.target" ]; timerConfig = { OnBootSec = cfg.imapSync.syncTimer; OnUnitActiveSec = cfg.imapSync.syncTimer; }; }; }) (lib.mkIf (cfg.enable && cfg.smtpRelay != null) ( let url = "[${cfg.smtpRelay.host}]:${toString cfg.smtpRelay.port}"; in { assertions = [ { assertion = lib.hasAttr cfg.adminPassword != null; message = "`shb.mailserver.adminPassword` must be not null if `shb.mailserver.adminUsername` is not null."; } ]; # Inspiration from https://www.brull.me/postfix/debian/fastmail/2016/08/16/fastmail-smtp.html services.postfix = { settings.main = { relayhost = [ url ]; smtp_sasl_auth_enable = "yes"; smtp_sasl_password_maps = "texthash:/run/secrets/postfix/postfix-smtp-relay-password"; smtp_sasl_security_options = "noanonymous"; smtp_use_tls = "yes"; }; }; systemd.services.postfix-pre = { script = shb.replaceSecrets { userConfig = { inherit url; inherit (cfg.smtpRelay) username; password.source = cfg.smtpRelay.password.result.path; }; generator = name: { url, username, password, }: pkgs.writeText "postfix-smtp-relay-password" '' ${url} ${username}:${password} ''; resultPath = "/run/secrets/postfix/postfix-smtp-relay-password"; user = config.services.postfix.user; }; serviceConfig.Type = "oneshot"; wantedBy = [ "multi-user.target" ]; before = [ "postfix.service" ]; requiredBy = [ "postfix.service" ]; }; } )) ]; } ================================================ FILE: modules/services/nextcloud-server/dashboard/Nextcloud.json ================================================ { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 0, "id": 13, "links": [], "panels": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 0, "w": 24, "x": 0, "y": 0 }, "id": 19, "options": { "dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": false, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.3.0+security-01", "repeat": "other_service", "repeatDirection": "h", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{unit=\"$other_service.service\"} | json | line_format \"{{.message}}\" | json | drop message | line_format \"[{{.app}} - {{.url}}] {{.Message}}: {{.exception_details}}{{.Previous_Message}}\"", "queryType": "range", "refId": "A" } ], "title": "Panel Title", "type": "logs" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 12, "panels": [], "title": "General", "type": "row" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "Some stall time means that 100% of the CPU is used. It's not an issue if this happens occasionally but can mean the CPU is underpowered for the current use case if this happens most of the time.\nTo fix this, the \"nice\" property of processes can be adjusted.", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "percent" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 34 }, { "id": "min", "value": -40 } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 1 }, "id": 8, "options": { "legend": { "calcs": [ "max", "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_cpu_some_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "some stall time", "range": true, "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_cpu_utilization_percentage_average{hostname=~\"$hostname\", service_name=~\".*$service.*\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{service_name}} / {{dimension}}", "range": true, "refId": "used", "useBackend": false } ], "title": "CPU", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "axisSoftMin": -100, "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "mbytes" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "A" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "decimals" }, { "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 10, 10 ], "fill": "dash" } }, { "id": "custom.lineWidth", "value": 2 } ] }, { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 10 }, { "id": "custom.axisPlacement", "value": "auto" }, { "id": "custom.stacking", "value": { "group": "A", "mode": "none" } } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 1 }, "id": 7, "options": { "legend": { "calcs": [ "max", "lastNotNull" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_memory_full_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "full stall time", "range": true, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "sum(netdata_mem_available_MiB_average{hostname=~\"$hostname\"})", "hide": false, "instant": false, "legendFormat": "total available", "range": true, "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_memory_usage_MiB_average{hostname=~\"$hostname\", service_name=~\".*$service.*\", dimension=\"ram\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{service_name}}", "range": true, "refId": "used", "useBackend": false } ], "title": "Memory", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "KBs" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 34 } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 }, "id": 4, "options": { "legend": { "calcs": [ "max", "mean" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension) (netdata_system_net_kilobits_persec_average{hostname=~\"$hostname\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{dimension}}", "range": true, "refId": "used", "useBackend": false } ], "title": "Network I/O", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "decimals": 2, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "transparent", "value": 0.05 } ] }, "unit": "Kibits" }, "overrides": [ { "matcher": { "id": "byFrameRefID", "options": "A" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 12 }, { "id": "custom.stacking", "value": { "group": "A", "mode": "none" } }, { "id": "min", "value": -200 } ] }, { "matcher": { "id": "byFrameRefID", "options": "B" }, "properties": [ { "id": "custom.axisPlacement", "value": "right" }, { "id": "unit", "value": "ms" }, { "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "dash": [ 0, 10 ], "fill": "dot" } }, { "id": "custom.lineWidth", "value": 2 }, { "id": "custom.fillOpacity", "value": 17 }, { "id": "custom.stacking", "value": { "group": "A", "mode": "none" } }, { "id": "min", "value": -200 } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, "id": 6, "options": { "legend": { "calcs": [ "max", "sum" ], "displayMode": "table", "placement": "right", "showLegend": true, "width": 300 }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_io_full_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "full stall time", "range": true, "refId": "A" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "netdata_system_io_some_pressure_stall_time_ms_average{hostname=~\"$hostname\"} * -1", "hide": false, "instant": false, "legendFormat": "some stall time", "range": true, "refId": "B" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "disableTextWrap": false, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{hostname=~\"$hostname\", service_name=~\".*$service.*\", dimension=\"read\"})", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "{{service_name}} / {{dimension}}", "range": true, "refId": "read", "useBackend": false }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "sum by(dimension, service_name) (netdata_systemd_service_disk_io_KiB_persec_average{hostname=~\"$hostname\", service_name=~\".*$service.*\", dimension=\"write\"}) * -1", "hide": false, "instant": false, "legendFormat": "{{service_name}} / {{dimension}}", "range": true, "refId": "write" } ], "title": "Disk I/O", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "", "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "area" } }, "fieldMinMax": false, "mappings": [], "thresholds": { "mode": "percentage", "steps": [ { "color": "transparent", "value": null }, { "color": "orange", "value": 80 }, { "color": "red", "value": 90 }, { "color": "transparent", "value": 100 } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "total" }, "properties": [ { "id": "custom.hideFrom", "value": { "legend": true, "tooltip": false, "viz": false } }, { "id": "custom.lineWidth", "value": 0 } ] } ] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 17 }, "id": 22, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "editorMode": "code", "expr": "sum (phpfpm_active_processes{hostname=~\"$hostname\",pool=\"nextcloud\"})", "hide": false, "legendFormat": "active", "range": true, "refId": "active" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "sum (phpfpm_total_processes{hostname=~\"$hostname\",pool=\"nextcloud\"})", "hide": false, "instant": false, "legendFormat": "total", "range": true, "refId": "total" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "sum (phpfpm_max_active_processes{hostname=~\"$hostname\",pool=\"nextcloud\"})", "hide": false, "instant": false, "legendFormat": "max active", "range": true, "refId": "max active" } ], "title": "PHP-FPM Processes", "transformations": [ { "disabled": true, "id": "calculateField", "options": { "binary": { "left": { "matcher": { "id": "byName", "options": "total" } }, "operator": "*", "right": { "fixed": "0.8" } }, "mode": "binary", "reduce": { "reducer": "sum" } } } ], "type": "timeseries" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 }, "id": 14, "panels": [], "title": "Network", "type": "row" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "description": "If requests occasionally take longer than the threshold time, that's fine. If instead most of the queries take longer than the threshold, performance issue should be investigated.", "fieldConfig": { "defaults": { "color": { "fixedColor": "purple", "mode": "fixed", "seriesBy": "max" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "dashed+area" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "transparent", "value": null }, { "color": "red", "value": 700000 } ] }, "unit": "µs" }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 }, "id": 23, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": false }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "disableTextWrap": false, "editorMode": "code", "expr": "max by (hostname,child) (phpfpm_process_request_duration and (abs(phpfpm_process_request_duration - phpfpm_process_request_duration offset $__interval) > 1))", "fullMetaSearch": false, "hide": false, "includeNullMetadata": true, "legendFormat": "__auto", "range": true, "refId": "A", "useBackend": false } ], "title": "PHP-FPM Request Duration", "type": "timeseries" }, { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 }, "id": 24, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "editorMode": "code", "expr": "rate(phpfpm_max_listen_queue[2m])", "hide": false, "instant": false, "legendFormat": "{{hostname}} - max queue", "range": true, "refId": "B" }, { "editorMode": "code", "expr": "phpfpm_listen_queue", "legendFormat": "{{hostname}} - queue", "range": true, "refId": "A" } ], "title": "PHP-FPM Requests Queue Length", "type": "timeseries" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "points", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] }, "unit": "ms" }, "overrides": [] }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 34 }, "id": 9, "options": { "legend": { "calcs": [ "max", "mean", "variance" ], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "maxHeight": 600, "mode": "single", "sort": "none" } }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | server_name =~ \"^$subdomain.*\"", "legendFormat": "", "queryType": "range", "refId": "A" } ], "title": "Requests", "transformations": [ { "id": "extractFields", "options": { "keepTime": true, "replace": true, "source": "labels" } }, { "id": "organize", "options": { "excludeByName": { "body_bytes_sent": true, "bytes_sent": true, "gzip_ration": true, "job": true, "line": true, "post": true, "referrer": true, "remote_addr": false, "remote_user": true, "request": true, "request_length": true, "status": true, "time_local": true, "unit": true, "upstream_addr": true, "upstream_connect_time": true, "upstream_header_time": true, "upstream_response_time": true, "upstream_status": false, "user_agent": true }, "includeByName": {}, "indexByName": {}, "renameByName": {} } }, { "id": "convertFieldType", "options": { "conversions": [ { "dateFormat": "", "destinationType": "number", "targetField": "request_time" } ], "fields": {} } }, { "id": "partitionByValues", "options": { "fields": [ "server_name", "remote_addr" ], "keepFields": false } }, { "id": "renameByRegex", "options": { "regex": "request_time (.*)", "renamePattern": "$1" } } ], "type": "timeseries" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "status" }, "properties": [ { "id": "custom.width", "value": 70 } ] } ] }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 42 }, "id": 3, "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": [ "sum" ], "show": false }, "showHeader": true, "sortBy": [ { "desc": true, "displayName": "Time" } ] }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | upstream_addr =~ \"$upstream_addr\"", "queryType": "range", "refId": "A" } ], "title": "Requests Details", "transformations": [ { "id": "extractFields", "options": { "source": "Line" } }, { "id": "organize", "options": { "excludeByName": { "Line": true, "id": true, "labels": true, "server_name": true, "time_local": true, "tsNs": true, "upstream_addr": true }, "includeByName": {}, "indexByName": { "Line": 2, "Time": 1, "body_bytes_sent": 13, "bytes_sent": 12, "gzip_ration": 16, "id": 4, "labels": 0, "post": 17, "referrer": 14, "remote_addr": 7, "remote_user": 8, "request": 6, "request_length": 10, "request_time": 20, "server_name": 11, "status": 5, "time_local": 9, "tsNs": 3, "upstream_addr": 18, "upstream_connect_time": 22, "upstream_header_time": 23, "upstream_response_time": 21, "upstream_status": 19, "user_agent": 15 }, "renameByName": {} } } ], "type": "table" }, { "datasource": { "default": false, "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "status" }, "properties": [ { "id": "custom.width", "value": 70 } ] } ] }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 50 }, "id": 11, "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": [ "sum" ], "show": false }, "showHeader": true, "sortBy": [] }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"nginx.service\"} | pattern \"<_> <_> \" | line_format \"{{.line}}\" | json | __error__ != \"JSONParserErr\" | upstream_addr = \"$upstream_addr\" | status =~\"5..\"", "queryType": "range", "refId": "A" } ], "title": "5XX Requests Details", "transformations": [ { "id": "extractFields", "options": { "source": "Line" } }, { "id": "organize", "options": { "excludeByName": { "Line": true, "id": true, "labels": true, "server_name": true, "time_local": true, "tsNs": true, "upstream_addr": true }, "includeByName": {}, "indexByName": { "Line": 2, "Time": 1, "body_bytes_sent": 13, "bytes_sent": 12, "gzip_ration": 16, "id": 4, "labels": 0, "post": 17, "referrer": 14, "remote_addr": 7, "remote_user": 8, "request": 6, "request_length": 10, "request_time": 20, "server_name": 11, "status": 5, "time_local": 9, "tsNs": 3, "upstream_addr": 18, "upstream_connect_time": 22, "upstream_header_time": 23, "upstream_response_time": 21, "upstream_status": 19, "user_agent": 15 }, "renameByName": {} } } ], "type": "table" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 58 }, "id": 18, "panels": [], "title": "Logs", "type": "row" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 10, "w": 24, "x": 0, "y": 59 }, "id": 20, "maxPerRow": 2, "options": { "dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "repeat": "other_service", "repeatDirection": "h", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"$other_service.service\"} | line_format \"{{ if hasPrefix \\\"{\\\" __line__ }}{{ with $parsed := fromJson __line__ }}[{{.app}} - {{.url}}] {{ if hasPrefix \\\"{\\\" .message }}{{ with $mParsed := fromJson .message }}{{ $mParsed.Message }} @ {{ $mParsed.File }}:{{ $mParsed.Line }}{{ end }}{{ else }}{{ .message }}{{ end }}{{ end }}{{ else }}{{ __line__ }}{{ end }}\"", "queryType": "range", "refId": "A" } ], "title": "Log: $other_service", "type": "logs" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 89 }, "id": 15, "panels": [], "title": "Backup", "type": "row" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 10, "w": 24, "x": 0, "y": 90 }, "id": 16, "maxPerRow": 2, "options": { "dedupStrategy": "none", "enableLogDetails": true, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "repeat": "service_backup", "repeatDirection": "h", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"$service_backup.service\"}", "legendFormat": "", "queryType": "range", "refId": "A" } ], "title": "Log: $service_backup", "transformations": [ { "disabled": true, "id": "extractFields", "options": { "source": "Line" } }, { "disabled": true, "id": "organize", "options": { "excludeByName": { "Line": true, "id": true, "labels": true, "tsNs": true }, "includeByName": {}, "indexByName": {}, "renameByName": {} } } ], "type": "logs" }, { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 100 }, "id": 13, "panels": [], "title": "Supporting Services", "type": "row" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 80 } ] } }, "overrides": [ { "matcher": { "id": "byName", "options": "duration_ms" }, "properties": [ { "id": "custom.width", "value": 100 } ] }, { "matcher": { "id": "byName", "options": "unit" }, "properties": [ { "id": "custom.width", "value": 150 } ] }, { "matcher": { "id": "byName", "options": "statement" }, "properties": [ { "id": "custom.width", "value": 505 } ] } ] }, "gridPos": { "h": 8, "w": 24, "x": 0, "y": 101 }, "id": 10, "links": [ { "title": "explore", "url": "https://grafana.tiserbox.com/explore?panes=%7B%22HWt%22:%7B%22datasource%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bunit%3D%5C%22nginx.service%5C%22%7D%20%7C%20pattern%20%5C%22%3C_%3E%20%3C_%3E%20%3Cline%3E%5C%22%20%7C%20line_format%20%5C%22%7B%7B.line%7D%7D%5C%22%20%7C%20json%20%7C%20status%20%21~%20%5C%222..%5C%22%20%7C%20__error__%20%21%3D%20%5C%22JSONParserErr%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22cd6cc53e-840c-484d-85f7-96fede324006%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&schemaVersion=1&orgId=1" } ], "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": [ "sum" ], "show": false }, "showHeader": true, "sortBy": [ { "desc": true, "displayName": "Time" } ] }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{hostname=~\"$hostname\",unit=\"postgresql.service\"} | regexp \".*duration: (?P[0-9.]+) ms (?P.*)\" | duration_ms > 1000", "queryType": "range", "refId": "A" } ], "title": "Slow PostgreSQL Queries", "transformations": [ { "id": "extractFields", "options": { "keepTime": false, "replace": false, "source": "labels" } }, { "id": "organize", "options": { "excludeByName": { "Line": true, "Time": false, "id": true, "job": true, "labels": true, "tsNs": true, "unit": true }, "includeByName": {}, "indexByName": { "Line": 6, "Time": 0, "duration_ms": 1, "id": 8, "job": 2, "labels": 5, "statement": 4, "tsNs": 7, "unit": 3 }, "renameByName": {} } } ], "type": "table" }, { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "fieldConfig": { "defaults": {}, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 109 }, "id": 2, "options": { "dedupStrategy": "none", "enableLogDetails": false, "prettifyLogMessage": false, "showCommonLabels": false, "showLabels": false, "showTime": true, "sortOrder": "Descending", "wrapLogMessage": false }, "pluginVersion": "11.4.0", "targets": [ { "datasource": { "type": "loki", "uid": "cd6cc53e-840c-484d-85f7-96fede324006" }, "editorMode": "code", "expr": "{unit=\"redis-$service.service\"} |= ``", "queryType": "range", "refId": "A" } ], "title": "Redis", "type": "logs" } ], "preload": false, "schemaVersion": 40, "tags": [], "templating": { "list": [ { "current": { "text": [ "baryum" ], "value": [ "baryum" ] }, "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "definition": "label_values(up,hostname)", "includeAll": false, "multi": true, "name": "hostname", "options": [], "query": { "qryType": 1, "query": "label_values(up,hostname)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "type": "query" }, { "current": { "text": "nextcloud", "value": "nextcloud" }, "description": "", "hide": 2, "name": "service", "query": "nextcloud", "skipUrlSync": true, "type": "constant" }, { "current": { "text": "n", "value": "n" }, "hide": 2, "name": "subdomain", "query": "n", "skipUrlSync": true, "type": "constant" }, { "current": { "text": "unix:/run/phpfpm/nextcloud.sock", "value": "unix:/run/phpfpm/nextcloud.sock" }, "hide": 2, "name": "upstream_addr", "query": "unix:/run/phpfpm/nextcloud.sock", "skipUrlSync": true, "type": "constant" }, { "current": { "text": "All", "value": "$__all" }, "datasource": { "type": "prometheus", "uid": "df80f9f5-97d7-4112-91d8-72f523a02b09" }, "definition": "label_values({unit_name=~\".*$service.*\", unit_name=~\".*backups.*\", unit_name!~\".*restore_gen\"},unit_name)", "description": "", "hide": 2, "includeAll": true, "name": "service_backup", "options": [], "query": { "qryType": 1, "query": "label_values({unit_name=~\".*$service.*\", unit_name=~\".*backups.*\", unit_name!~\".*restore_gen\"},unit_name)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "sort": 1, "type": "query" }, { "current": { "text": "All", "value": "$__all" }, "definition": "label_values(netdata_systemd_service_unit_state_state_average{unit_name=~\".*nextcloud.*\", unit_name!~\".*backup.*\", unit_name!~\".*redis.*\"},unit_name)", "hide": 2, "includeAll": true, "name": "other_service", "options": [], "query": { "qryType": 1, "query": "label_values(netdata_systemd_service_unit_state_state_average{unit_name=~\".*nextcloud.*\", unit_name!~\".*backup.*\", unit_name!~\".*redis.*\"},unit_name)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, "regex": "", "type": "query" } ] }, "time": { "from": "now-2d", "to": "now" }, "timepicker": {}, "timezone": "browser", "title": "Nextcloud", "uid": "cdsszybv2gow0d", "version": 49, "weekStart": "" } ================================================ FILE: modules/services/nextcloud-server/docs/default.md ================================================ # Nextcloud Server Service {#services-nextcloudserver} Defined in [`/modules/services/nextcloud-server.nix`](@REPO@/modules/services/nextcloud-server.nix). This NixOS module is a service that sets up a [Nextcloud Server](https://nextcloud.com/). It is based on the nixpkgs Nextcloud server and provides opinionated defaults. ## Features {#services-nextcloudserver-features} - Declarative [Apps](#services-nextcloudserver-options-shb.nextcloud.apps) Configuration - no need to configure those with the UI. - [LDAP](#services-nextcloudserver-usage-ldap) app: enables app and sets up integration with an existing LDAP server, in this case LLDAP. Note that the LDAP app cannot distinguish between normal users and admin users. - [SSO](#services-nextcloudserver-usage-oidc) app: enables app and sets up integration with an existing SSO server, in this case Authelia. The SSO app can distinguish between normal users and admin users. - [Preview Generator](#services-nextcloudserver-usage-previewgenerator) app: enables app and sets up required cron job. - [External Storage](#services-nextcloudserver-usage-externalstorage) app: enables app and optionally configures one local mount. This enables having data living on separate hard drives. - [Only Office](#services-nextcloudserver-usage-onlyoffice) app: enables app and sets up Only Office service. - [Memories](#services-nextcloudserver-usage-memories) app: enables app and sets up all required dependencies and optional hardware acceleration with VAAPI. - [Recognize](#services-nextcloudserver-usage-recognize) app: enables app and sets up all required dependencies and optional hardware acceleration with VAAPI. - Any other app through the [shb.nextcloud.extraApps](#services-nextcloudserver-options-shb.nextcloud.extraApps) option. - Access through subdomain using reverse proxy. - Forces Nginx as the reverse proxy. (This is hardcoded in the upstream nixpkgs module). - Sets good defaults for trusted proxies settings, chunk size, opcache php options. - Access through HTTPS using reverse proxy. - Forces PostgreSQL as the database. - Forces Redis as the cache and sets good defaults. - Backup of the [`shb.nextcloud.dataDir`][dataDir] through the [backup block](./blocks-backup.html). - [Monitoring Dashboard](#services-nextcloudserver-dashboard) for monitoring of reverse proxy, PHP-FPM, and database backups through the [monitoring block](./blocks-monitoring.html). - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. - [Integration Tests](@REPO@/test/services/nextcloud.nix) - Tests system cron job is setup correctly. - Tests initial admin user and password are setup correctly. - Tests admin user can create and retrieve a file through WebDAV. - Enables easy setup of xdebug for PHP debugging if needed. - Easily add other apps declaratively through [extraApps][] - By default automatically disables maintenance mode on start. - By default automatically launches repair mode with expensive migrations on start. - Access to advanced options not exposed here thanks to how NixOS modules work. - Has a [demo](#services-nextcloudserver-demo). [dataDir]: ./services-nextcloud.html#services-nextcloudserver-options-shb.nextcloud.dataDir ## Usage {#services-nextcloudserver-usage} ### Nextcloud through HTTP {#services-nextcloudserver-usage-basic} [HTTP]: #services-nextcloudserver-usage-basic :::: {.note} This section corresponds to the `basic` section of the [Nextcloud demo](demo-nextcloud-server.html#demo-nextcloud-deploy-basic). :::: Configuring Nextcloud to be accessible through Nginx reverse proxy at the address `http://n.example.com`, with PostgreSQL and Redis configured, is done like so: ```nix shb.nextcloud = { enable = true; domain = "example.com"; subdomain = "n"; defaultPhoneRegion = "US"; initialAdminUsername = "root"; adminPass.result = config.shb.sops.secret."nextcloud/adminpass".result; }; shb.sops.secret."nextcloud/adminpass".request = config.shb.nextcloud.adminPass.request; ``` This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. Note though that Nextcloud will not be very happy to be accessed through HTTP, it much prefers - rightfully - to be accessed through HTTPS. We will set that up in the next section. You can now login as the admin user using the username `root` and the password defined in `sops.secrets."nextcloud/adminpass"`. ### Nextcloud through HTTPS {#services-nextcloudserver-usage-https} [HTTPS]: #services-nextcloudserver-usage-https To setup HTTPS, we will get our certificates from Let's Encrypt using the HTTP method. This is the easiest way to get started and does not require you to programmatically configure a DNS provider. Under the hood, we use the Self Host Block [SSL contract](./contracts-ssl.html). It allows the end user to choose how to generate the certificates. If you want other options to generate the certificate, follow the SSL contract link. Building upon the [Basic Configuration](#services-nextcloudserver-usage-basic) above, we add: ```nix shb.certs.certs.letsencrypt."example.com" = { domain = "example.com"; group = "nginx"; reloadServices = [ "nginx.service" ]; adminEmail = "myemail@mydomain.com"; }; shb.certs.certs.letsencrypt."example.com".extraDomains = [ "n.example.com" ]; shb.nextcloud = { ssl = config.shb.certs.certs.letsencrypt."example.com"; }; ``` ### Choose Nextcloud Version {#services-nextcloudserver-usage-version} Self Host Blocks is conservative in the version of Nextcloud it's using. To choose the version and upgrade at the time of your liking, just use the [version](#services-nextcloudserver-options-shb.nextcloud.version) option: ```nix shb.nextcloud.version = 29; ``` ### Mount Point {#services-nextcloudserver-usage-mount-point} If the `dataDir` exists in a mount point, it is highly recommended to make the various Nextcloud services wait on the mount point before starting. Doing that is just a matter of setting the `mountPointServices` option. Assuming a mount point on `/var`, the configuration would look like so: ```nix fileSystems."/var".device = "..."; shb.nextcloud.mountPointServices = [ "var.mount" ]; ``` ### With LDAP Support {#services-nextcloudserver-usage-ldap} [LDAP]: #services-nextcloudserver-usage-ldap :::: {.note} This section corresponds to the `ldap` section of the [Nextcloud demo](demo-nextcloud-server.html#demo-nextcloud-deploy-ldap). :::: We will build upon the [HTTP][] and [HTTPS][] sections, so please read those first. We will use the [LLDAP block][] provided by Self Host Blocks. Assuming it [has been set already][LLDAP block setup], add the following configuration: [LLDAP block]: blocks-lldap.html [LLDAP block setup]: blocks-lldap.html#blocks-lldap-global-setup ```nix shb.nextcloud.apps.ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminName = "admin"; adminPassword.result = config.shb.sops.secret."nextcloud/ldap/adminPassword".result userGroup = "nextcloud_user"; }; shb.sops.secret."nextcloud/ldap/adminPassword" = { request = config.shb.nextcloud.apps.ldap.adminPassword.request; settings.key = "ldap/userPassword"; }; ``` The LDAP admin password must be shared between `shb.lldap` and `shb.nextcloud`, to do that with SOPS we use the `key` option so that both `sops.secrets."ldap/userPassword"` and `sops.secrets."nextcloud/ldapUserPassword"` secrets have the same content. The LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) is created automatically. Add your user to it by going to `http://ldap.example.com`, create a user if needed and add it to the group. When that's done, go back to the Nextcloud server at `https://nextcloud.example.com` and login with that user. Note that we cannot create an admin user from the LDAP server, so you need to create a normal user like above, login with it once so it is known to Nextcloud, then logout, login with the admin Nextcloud user and promote that new user to admin level. This limitation does not exist with the [SSO integration](#services-nextcloudserver-usage-oidc). ### With SSO Support {#services-nextcloudserver-usage-oidc} :::: {.note} This section corresponds to the `sso` section of the [Nextcloud demo](demo-nextcloud-server.html#demo-nextcloud-deploy-sso). :::: We will build upon the [HTTP][], [HTTPS][] and [LDAP][] sections, so please read those first. We will use the [SSO block][] provided by Self Host Blocks. Assuming it [has been set already][SSO block setup], add the following configuration: [SSO block]: blocks-sso.html [SSO block setup]: blocks-sso.html#blocks-sso-global-setup ```nix shb.nextcloud.apps.sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; clientID = "nextcloud"; fallbackDefaultAuth = false; secret.result = config.shb.sops.secret."nextcloud/sso/secret".result; secretForAuthelia.result = config.shb.sops.secret."nextcloud/sso/secretForAuthelia".result; }; shb.sops.secret."nextcloud/sso/secret".request = config.shb.nextcloud.apps.sso.secret.request; shb.sops.secret."nextcloud/sso/secretForAuthelia" = { request = config.shb.nextcloud.apps.sso.secretForAuthelia.request; settings.key = "nextcloud/sso/secret"; }; ``` The SSO secret must be shared between `shb.authelia` and `shb.nextcloud`, to do that with SOPS we use the `key` option so that both `sops.secrets."nextcloud/sso/secret"` and `sops.secrets."nextcloud/sso/secretForAuthelia"` secrets have the same content. The LDAP [user group](#services-nextcloudserver-options-shb.nextcloud.apps.ldap.userGroup) and [admin group](#services-nextcloudserver-options-shb.nextcloud.apps.sso.adminGroup) are created automatically. Add your user to one or both by going to `http://ldap.example.com`, create a user if needed and add it to the groups. When that's done, go back to the Nextcloud server at `https://nextcloud.example.com` and login with that user. Setting the `fallbackDefaultAuth` to `false` means the only way to login is through Authelia. If this does not work for any reason, you can let users login through Nextcloud directly by setting this option to `true`. ### Tweak PHPFpm Config {#services-nextcloudserver-usage-phpfpm} For instances with more users, or if you feel the pages are loading slowly, you can tweak the `php-fpm` pool settings. ```nix shb.nextcloud.phpFpmPoolSettings = { "pm" = "static"; # Can be dynamic "pm.max_children" = 150; # "pm.start_servers" = 300; # "pm.min_spare_servers" = 300; # "pm.max_spare_servers" = 500; # "pm.max_spawn_rate" = 50; # "pm.max_requests" = 50; # "pm.process_idle_timeout" = "20s"; }; ``` I don't have a good heuristic for what are good values here but what I found is that you don't want too high of a `max_children` value to avoid I/O strain on the hard drives, especially if you use spinning drives. To see the effect of your settings, go to the provided [Grafana dashboard](#services-nextcloudserver-dashboard). ### Tweak PostgreSQL Settings {#services-nextcloudserver-usage-postgres} These settings will impact all databases since the NixOS Postgres module configures only one Postgres instance. To know what values to put here, use [https://pgtune.leopard.in.ua/](https://pgtune.leopard.in.ua/). Remember the server hosting PostgreSQL is shared at least with the Nextcloud service and probably others. So to avoid PostgreSQL hogging all the resources, reduce the values you give on that website for CPU, available memory, etc. For example, I put 12 GB of memory and 4 CPUs while I had more: - `DB Version`: 14 - `OS Type`: linux - `DB Type`: dw - `Total Memory (RAM)`: 12 GB - `CPUs num`: 4 - `Data Storage`: ssd And got the following values: ```nix shb.nextcloud.postgresSettings = { max_connections = "400"; shared_buffers = "3GB"; effective_cache_size = "9GB"; maintenance_work_mem = "768MB"; checkpoint_completion_target = "0.9"; wal_buffers = "16MB"; default_statistics_target = "100"; random_page_cost = "1.1"; effective_io_concurrency = "200"; work_mem = "7864kB"; huge_pages = "off"; min_wal_size = "1GB"; max_wal_size = "4GB"; max_worker_processes = "4"; max_parallel_workers_per_gather = "2"; max_parallel_workers = "4"; max_parallel_maintenance_workers = "2"; }; ``` To see the effect of your settings, go to the provided [Grafana dashboard](#services-nextcloudserver-dashboard). ### Backup {#services-nextcloudserver-usage-backup} Backing up Nextcloud data files using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."nextcloud" = { request = config.shb.nextcloud.backup; settings = { enable = true; }; }; ``` The name `"nextcloud"` in the `instances` can be anything. The `config.shb.nextcloud.backup` option provides what directories to backup. You can define any number of Restic instances to backup Nextcloud multiple times. For backing up the Nextcloud database using the same Restic block, do like so: ```nix shb.restic.instances."postgres" = { request = config.shb.postgresql.databasebackup; settings = { enable = true; }; }; ``` Note that this will backup the whole PostgreSQL instance, not just the Nextcloud database. This limitation will be lifted in the future. ### Application Dashboard {#services-nextcloudserver-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-nextcloudserver-options-shb.nextcloud.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Documents.services.Nextcloud = { sortOrder = 1; dashboard.request = config.shb.nextcloud.dashboard.request; }; } ``` ### Enable Preview Generator App {#services-nextcloudserver-usage-previewgenerator} The following snippet installs and enables the [Preview Generator](https://apps.nextcloud.com/apps/previewgenerator) application as well as creates the required cron job that generates previews every 10 minutes. ```nix shb.nextcloud.apps.previewgenerator.enable = true; ``` Note that you still need to generate the previews for any pre-existing files with: ```bash nextcloud-occ -vvv preview:generate-all ``` The default settings generates all possible sizes which is a waste since most are not used. SHB will change the generation settings to optimize disk space and CPU usage as outlined in [this article](http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/). You can opt-out with: ```nix shb.nextcloud.apps.previewgenerator.recommendedSettings = false; ``` ### Enable External Storage App {#services-nextcloudserver-usage-externalstorage} The following snippet installs and enables the [External Storage](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage) application. ```nix shb.nextcloud.apps.externalStorage.enable = true; ``` Adding external storage can then be done through the UI. For the special case of mounting a local folder as an external storage, Self Host Blocks provides options. The following snippet will mount the `/srv/nextcloud/$user` local file in each user's `/home` Nextcloud directory. ```nix shb.nextcloud.apps.externalStorage.userLocalMount = { rootDirectory = "/srv/nextcloud/$user"; mountName = "home"; }; ``` You can even make the external storage mount in the root `/` Nextcloud directory with: ```nix shb.nextcloud.apps.externalStorage.userLocalMount = { mountName = "/"; }; ``` Recommended use of this app is to have the Nextcloud's `dataDir` on a SSD and the `userLocalMount` on a HDD. Indeed, a SSD is much quicker than a spinning hard drive, which is well suited for randomly accessing small files like thumbnails. On the other side, a spinning hard drive can store more data which is well suited for storing user data. This Nextcloud module includes a patch that allows the external storage to actually create the local path. Normally, when login in for the first time, the user will be greeted with an error saying the external storage path does not exist. One must then create it manually. With this patch, Nextcloud creates the path. ### Enable OnlyOffice App {#services-nextcloudserver-usage-onlyoffice} The following snippet installs and enables the [Only Office](https://apps.nextcloud.com/apps/onlyoffice) application as well as sets up an Only Office instance listening at `onlyoffice.example.com` that only listens on the local network. ```nix shb.nextcloud.apps.onlyoffice = { enable = true; subdomain = "onlyoffice"; localNextworkIPRange = "192.168.1.1/24"; }; ``` Also, you will need to explicitly allow the package `corefonts`: ```nix nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "corefonts" ]; ``` ### Enable Memories App {#services-nextcloudserver-usage-memories} The following snippet installs and enables the [Memories](https://apps.nextcloud.com/apps/memories) application. ```nix shb.nextcloud.apps.memories = { enable = true; vaapi = true; # If hardware acceleration is supported. photosPath = "/Photos"; # This is the default. }; ``` All the following dependencies are installed correctly and fully declaratively, the config page is "all green": - Exiftool with the correct version - Indexing path is set to `/Photos` by default. - Images, HEIC, videos preview generation. - Performance is all green with database triggers. - Recommended apps are - Albums: this is installed by default. - Recognize can be installed [here](#services-nextcloudserver-usage-recognize) - Preview Generator can be installed [here](#services-nextcloudserver-usage-previewgenerator) - Reverse Geocoding must be triggered manually with `nextcloud-occ memories:places-setup `. - Video streaming is setup by installed ffmpeg headless. - Transcoder is setup natively (not with slow WASM) wit `go-vod` binary. - Hardware Acceleration is optionally setup by setting `vaapi` to `true`. It is not required but you can for the first indexing with `nextcloud-occ memories:index`. Note that the app is not configurable through the UI since the config file is read-only. ### Enable Recognize App {#services-nextcloudserver-usage-recognize} The following snippet installs and enables the [Recognize](https://apps.nextcloud.com/apps/recognize) application. ```nix shb.nextcloud.apps.recognize = { enable = true; }; ``` The required dependencies are installed: `nodejs` and `nice`. ### Enable Monitoring {#services-nextcloudserver-server-usage-monitoring} Enable the [monitoring block](./blocks-monitoring.html). A [Grafana dashboard][] for overall server performance will be created and the Nextcloud metrics will automatically appear there. [Grafana dashboard]: ./blocks-monitoring.html#blocks-monitoring-performance-dashboard ### Enable Tracing {#services-nextcloudserver-server-usage-tracing} You can enable tracing with: ```nix shb.nextcloud.debug = true; ``` Traces will be located at `/var/log/xdebug`. See [my blog post][] for how to look at the traces. I want to make the traces available in Grafana directly but that's not the case yet. [my blog post]: http://blog.tiserbox.com/posts/2023-08-12-what%27s-up-with-nextcloud-webdav-slowness.html ### Appdata Location {#services-nextcloudserver-server-usage-appdata} The appdata folder is a special folder located under the `shb.nextcloud.dataDir` directory. It is named `appdata_` with the Nextcloud's instance ID as a suffix. You can find your current instance ID with `nextcloud-occ config:system:get instanceid`. In there, you will find one subfolder for every installed app that needs to store files. For performance reasons, it is recommended to store this folder on a fast drive that is optimized for randomized read and write access. The best would be either an SSD or an NVMe drive. The best way to solve this is to use the [External Storage app](#services-nextcloudserver-usage-externalstorage). If you have an existing installation and put Nextcloud's `shb.nextcloud.dataDir` folder on a HDD with spinning disks, then the appdata folder is also located on spinning drives. One way to solve this is to bind mount a folder from an SSD over the appdata folder. SHB does not provide a declarative way to setup this as the external storage app is the preferred way but this command should be enough: ```bash mount /dev/sdd /srv/sdd mkdir -p /srv/sdd/appdata_nextcloud mount --bind /srv/sdd/appdata_nextcloud /var/lib/nextcloud/data/appdata_ocxvky2f5ix7 ``` Note that you can re-generate a new appdata folder by issuing the command `nextcloud-occ config:system:delete instanceid`. ## Demo {#services-nextcloudserver-demo} Head over to the [Nextcloud demo](demo-nextcloud-server.html) for a demo that installs Nextcloud with or without LDAP integration on a VM with minimal manual steps. ## Monitoring Dashboard {#services-nextcloudserver-dashboard} The dashboard is added to Grafana automatically under "Self Host Blocks > Nextcloud" as long as the Nextcloud service is [enabled][] as well as the [monitoring block][]. [enabled]: #services-nextcloudserver-options-shb.nextcloud.enable [monitoring block]: ./blocks-monitoring.html#blocks-monitoring-options-shb.monitoring.enable - The *General* section shows Nextcloud related services. This includes cronjobs, Redis and backup jobs. - *CPU* shows stall time which means CPU is maxed out. This graph is inverted so having a small area at the top means the stall time is low. - *Memory* shows stall time which means some job is waiting on memory to be allocated. This graph is inverted so having a small area at the top means the stall time is low. Some stall time will always be present. Under 10% is fine but having constantly over 50% usually means available memory is low and SWAP is being used. *Memory* also shows available memory which is the remaining allocatable memory. - Caveat: *Network I/O* shows the network input and output for all services running, not only those related to Nextcloud. - *Disk I/O* shows "some" stall time which means some jobs were waiting on disk I/O. Disk is usually the slowest bottleneck so having "some" stall time is not surprising. Fixing this can be done by using disks allowing higher speeds or switching to SSDs. If the "full" stall time is shown, this means _all_ jobs were waiting on disk i/o which can be more worrying. This could indicate a failing disk if "full" stall time appeared recently. These graphs are inverted so having a small area at the top means the stall time is low. *Memory* also shows available memory which is the remaining allocatable memory. ![Nextcloud Dashboard First Part](./assets/dashboards_Nextcloud_1.png) - *PHP-FPM Processes* shows how many processes are used by PHP-FPM. The orange area goes from 80% to 90% of the maximum allowed processes. The read area goes from 90% to 100% of the maximum allowed processes. If the number of active processes reaches those areas once in a while, that's fine but if it happens most of the time, the maximum allowed processes should be increased. - *PHP-FPM Request Duration* shows one dot per request and how long it took. Request time is fine if it is under 400ms. If most requests take longer than that, some [tracing](#services-nextcloudserver-server-usage-tracing) is required to understand which subsystem is taking some time. That being said, maybe another graph in this dashboard will show why the requests are slow - like disk or other processes hoarding some resources running at the same time. - *PHP-FPM Requests Queue Length* shows how many requests are waiting to be picked up by a PHP-FPM process. Usually, this graph won't show anything as long as the *PHP-FPM Processes* graph is not in the red area. Fixing this requires also increasing the maximum allowed processes. ![Nextcloud Dashboard Second Part](./assets/dashboards_Nextcloud_2.png) - *Requests Details* shows all requests to the Nextcloud service and the related headers. - *5XX Requests Details* shows only the requests having a 500 to 599 http status. Having any requests appearing here should be investigated as soon as possible. ![Nextcloud Dashboard Third Part](./assets/dashboards_Nextcloud_3.png) - *Log: \* shows all logs from related systemd `.service` job. Having no line here most often means the job ran at a time not currently included in the time range of the dashboard. ![Nextcloud Dashboard Fourth Part](./assets/dashboards_Nextcloud_4.png) ![Nextcloud Dashboard Fifth Part](./assets/dashboards_Nextcloud_5.png) - A lot of care has been taken to parse error messages correctly. Nextcloud mixes json and non-json messages so extracting errors from json messages was not that easy. Also, the stacktrace is reduced. The result though is IMO pretty nice as can be seen by the following screenshot. The top line is the original json message and the bottom one is the parsed error. ![Nextcloud Dashboard Error Parsing](./assets/dashboards_Nextcloud_error_parsing.png) - *Backup logs* show the output of the backup jobs. Here, there are two backup jobs, one for the core files of Nextcloud stored on an SSD which includes the appdata folder. The other backup job is for the external data stored on HDDs which contain all user files. ![Nextcloud Dashboard Sixth Part](./assets/dashboards_Nextcloud_6.png) - *Slow PostgreSQL queries* shows all database queries taking longer than 1s to run. - *Redis* shows all Redis log output. ![Nextcloud Dashboard Seventh Part](./assets/dashboards_Nextcloud_7.png) ## Debug {#services-nextcloudserver-debug} On the command line, the `occ` tool is called `nextcloud-occ`. In case of an issue, check the logs for any systemd service mentioned in this section. On startup, the oneshot systemd service `nextcloud-setup.service` starts. After it finishes, the `phpfpm-nextcloud.service` starts to serve Nextcloud. The `nginx.service` is used as the reverse proxy. `postgresql.service` run the database. Nextcloud' configuration is found at `${shb.nextcloud.dataDir}/config/config.php`. Nginx' configuration can be found with `systemctl cat nginx | grep -om 1 -e "[^ ]\+conf"`. Enable verbose logging by setting the `shb.nextcloud.debug` boolean to `true`. Access the database with `sudo -u nextcloud psql`. Access Redis with `sudo -u nextcloud redis-cli -s /run/redis-nextcloud/redis.sock`. ## Options Reference {#services-nextcloudserver-options} ```{=include=} options id-prefix: services-nextcloudserver-options- list-id: selfhostblocks-service-nextcloud-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/nextcloud-server.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.nextcloud; fqdn = "${cfg.subdomain}.${cfg.domain}"; fqdnWithPort = if isNull cfg.port then fqdn else "${fqdn}:${toString cfg.port}"; protocol = if !(isNull cfg.ssl) then "https" else "http"; ssoFqdnWithPort = if isNull cfg.apps.sso.port then cfg.apps.sso.endpoint else "${cfg.apps.sso.endpoint}:${toString cfg.apps.sso.port}"; nextcloudPkg = builtins.getAttr ("nextcloud" + builtins.toString cfg.version) pkgs; nextcloudApps = (builtins.getAttr ("nextcloud" + builtins.toString cfg.version + "Packages") pkgs).apps; occ = "${config.services.nextcloud.occ}/bin/nextcloud-occ"; in { imports = [ ../../lib/module.nix ../blocks/authelia.nix ../blocks/monitoring.nix (lib.mkRenamedOptionModule [ "shb" "nextcloud" "adminUser" ] [ "shb" "nextcloud" "initialAdminUsername" ] ) ]; options.shb.nextcloud = { enable = lib.mkEnableOption "the SHB Nextcloud service"; enableDashboard = lib.mkEnableOption "the Nextcloud SHB dashboard" // { default = true; }; subdomain = lib.mkOption { type = lib.types.str; description = '' Subdomain under which Nextcloud will be served. ``` .[:] ``` ''; example = "nextcloud"; }; domain = lib.mkOption { description = '' Domain under which Nextcloud is served. ``` .[:] ``` ''; type = lib.types.str; example = "domain.com"; }; port = lib.mkOption { description = '' Port under which Nextcloud will be served. If null is given, then the port is omitted. ``` .[:] ``` ''; type = lib.types.nullOr lib.types.port; default = null; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; externalFqdn = lib.mkOption { description = "External fqdn used to access Nextcloud. Defaults to .. This should only be set if you include the port when accessing Nextcloud."; type = lib.types.nullOr lib.types.str; example = "nextcloud.domain.com:8080"; default = null; }; version = lib.mkOption { description = "Nextcloud version to choose from."; type = lib.types.enum [ 32 33 ]; default = 32; }; dataDir = lib.mkOption { description = "Folder where Nextcloud will store all its data."; type = lib.types.str; default = "/var/lib/nextcloud"; }; mountPointServices = lib.mkOption { description = "If given, all the systemd services and timers will depend on the specified mount point systemd services."; type = lib.types.listOf lib.types.str; default = [ ]; example = lib.literalExpression ''["var.mount"]''; }; initialAdminUsername = lib.mkOption { type = lib.types.str; description = "Initial username of the admin user. Once it is set, it cannot be changed!"; default = "root"; }; adminPass = lib.mkOption { description = "Nextcloud admin password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "nextcloud"; restartUnits = [ "phpfpm-nextcloud.service" ]; }; }; }; maxUploadSize = lib.mkOption { default = "4G"; type = lib.types.str; description = '' The upload limit for files. This changes the relevant options in php.ini and nginx if enabled. ''; }; defaultPhoneRegion = lib.mkOption { type = lib.types.str; description = '' Two letters region defining default region. ''; example = "US"; }; postgresSettings = lib.mkOption { type = lib.types.nullOr (lib.types.attrsOf lib.types.str); default = null; description = '' Settings for the PostgreSQL database. Go to https://pgtune.leopard.in.ua/ and copy the generated configuration here. ''; example = lib.literalExpression '' { # From https://pgtune.leopard.in.ua/ with: # DB Version: 14 # OS Type: linux # DB Type: dw # Total Memory (RAM): 7 GB # CPUs num: 4 # Connections num: 100 # Data Storage: ssd max_connections = "100"; shared_buffers = "1792MB"; effective_cache_size = "5376MB"; maintenance_work_mem = "896MB"; checkpoint_completion_target = "0.9"; wal_buffers = "16MB"; default_statistics_target = "500"; random_page_cost = "1.1"; effective_io_concurrency = "200"; work_mem = "4587kB"; huge_pages = "off"; min_wal_size = "4GB"; max_wal_size = "16GB"; max_worker_processes = "4"; max_parallel_workers_per_gather = "2"; max_parallel_workers = "4"; max_parallel_maintenance_workers = "2"; } ''; }; phpFpmPoolSettings = lib.mkOption { type = lib.types.nullOr (lib.types.attrsOf lib.types.anything); description = "Settings for PHPFPM."; default = { "pm" = "static"; "pm.max_children" = 5; "pm.start_servers" = 5; }; example = lib.literalExpression '' { "pm" = "dynamic"; "pm.max_children" = 50; "pm.start_servers" = 25; "pm.min_spare_servers" = 10; "pm.max_spare_servers" = 20; "pm.max_spawn_rate" = 50; "pm.max_requests" = 50; "pm.process_idle_timeout" = "20s"; } ''; }; phpFpmPrometheusExporter = lib.mkOption { description = "Settings for exporting"; default = { }; type = lib.types.submodule { options = { enable = lib.mkOption { description = "Enable export of php-fpm metrics to Prometheus."; type = lib.types.bool; default = true; }; port = lib.mkOption { description = "Port on which the exporter will listen."; type = lib.types.port; default = 8300; }; }; }; }; apps = lib.mkOption { description = '' Applications to enable in Nextcloud. Enabling an application here will also configure various services needed for this application. Enabled apps will automatically be installed, enabled and configured, so no need to do that through the UI. You can still make changes but they will be overridden on next deploy. You can still install and configure other apps through the UI. ''; default = { }; type = lib.types.submodule { options = { onlyoffice = lib.mkOption { description = '' Only Office App. [Nextcloud App Store](https://apps.nextcloud.com/apps/onlyoffice) Enabling this app will also start an OnlyOffice instance accessible at the given subdomain from the given network range. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "Nextcloud OnlyOffice App"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Only Office will be served."; default = "oo"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; localNetworkIPRange = lib.mkOption { type = lib.types.str; description = "Local network range, to restrict access to Open Office to only those IPs."; default = "192.168.1.1/24"; }; jwtSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.path; description = '' File containing the JWT secret. This option is required. Must be readable by the nextcloud system user. ''; default = null; }; }; }; }; previewgenerator = lib.mkOption { description = '' Preview Generator App. [Nextcloud App Store](https://apps.nextcloud.com/apps/previewgenerator) Enabling this app will create a cron job running every minute to generate thumbnails for new and updated files. To generate thumbnails for already existing files, run: ``` nextcloud-occ -vvv preview:generate-all ``` ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "Nextcloud Preview Generator App"; recommendedSettings = lib.mkOption { type = lib.types.bool; description = '' Better defaults than the defaults. Taken from [this article](http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/). Sets the following options: ``` nextcloud-occ config:app:set previewgenerator squareSizes --value="32 256" nextcloud-occ config:app:set previewgenerator widthSizes --value="256 384" nextcloud-occ config:app:set previewgenerator heightSizes --value="256" nextcloud-occ config:system:set preview_max_x --type integer --value 2048 nextcloud-occ config:system:set preview_max_y --type integer --value 2048 nextcloud-occ config:system:set jpeg_quality --value 60 nextcloud-occ config:app:set preview jpeg_quality --value=60 ``` ''; default = true; example = false; }; debug = lib.mkOption { type = lib.types.bool; description = "Enable more verbose logging."; default = false; example = true; }; }; }; }; externalStorage = lib.mkOption { # TODO: would be nice to have quota include external storage but it's not supported for root: # https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_configuration.html#setting-storage-quotas description = '' External Storage App. [Manual](https://docs.nextcloud.com/server/28/go.php?to=admin-external-storage) Set `userLocalMount` to automatically add a local directory as an external storage. Use this option if you want to store user data in another folder or another hard drive altogether. In the `directory` option, you can use either `$user` and/or `$home` which will be replaced by the user's name and home directory. Recommended use of this option is to have the Nextcloud's `dataDir` on a SSD and the `userLocalRooDirectory` on a HDD. Indeed, a SSD is much quicker than a spinning hard drive, which is well suited for randomly accessing small files like thumbnails. On the other side, a spinning hard drive can store more data which is well suited for storing user data. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "Nextcloud External Storage App"; userLocalMount = lib.mkOption { default = null; description = "If set, adds a local mount as external storage."; type = lib.types.nullOr ( lib.types.submodule { options = { directory = lib.mkOption { type = lib.types.str; description = '' Local directory on the filesystem to mount. Use `$user` and/or `$home` which will be replaced by the user's name and home directory. ''; example = "/srv/nextcloud/$user"; }; mountName = lib.mkOption { type = lib.types.str; description = '' Path of the mount in Nextcloud. Use `/` to mount as the root. ''; default = ""; example = [ "home" "/" ]; }; }; } ); }; }; }; }; ldap = lib.mkOption { description = '' LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html) Enabling this app will create a new LDAP configuration or update one that exists with the given host. ''; default = { }; type = lib.types.nullOr ( lib.types.submodule { options = { enable = lib.mkEnableOption "LDAP app."; host = lib.mkOption { type = lib.types.str; description = '' Host serving the LDAP server. ''; default = "127.0.0.1"; }; port = lib.mkOption { type = lib.types.port; description = '' Port of the service serving the LDAP server. ''; default = 389; }; dcdomain = lib.mkOption { type = lib.types.str; description = "dc domain for ldap."; example = "dc=mydomain,dc=com"; }; adminName = lib.mkOption { type = lib.types.str; description = "Admin user of the LDAP server."; default = "admin"; }; adminPassword = lib.mkOption { description = "LDAP server admin password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "nextcloud"; restartUnits = [ "phpfpm-nextcloud.service" ]; }; }; }; userGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to login to Nextcloud."; default = "nextcloud_user"; }; configID = lib.mkOption { type = lib.types.int; description = '' Multiple LDAP configs can co-exist with only one active at a time. This option sets the config ID used by Self Host Blocks. ''; default = 50; }; }; } ); }; sso = lib.mkOption { description = '' SSO Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/oidc_auth.html) ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "SSO app."; endpoint = lib.mkOption { type = lib.types.str; description = "OIDC endpoint for SSO."; example = "https://authelia.example.com"; }; port = lib.mkOption { description = "If given, adds a port to the endpoint."; type = lib.types.nullOr lib.types.port; default = null; }; provider = lib.mkOption { type = lib.types.enum [ "Authelia" ]; description = "OIDC provider name, used for display."; default = "Authelia"; }; clientID = lib.mkOption { type = lib.types.str; description = "Client ID for the OIDC endpoint."; default = "nextcloud"; }; authorization_policy = lib.mkOption { type = lib.types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; adminGroup = lib.mkOption { type = lib.types.str; description = '' Group admins must belong to to be able to login to Nextcloud. This option is purposely not inside the LDAP app because only SSO allows distinguising between users and admins. ''; default = "nextcloud_admin"; }; secret = lib.mkOption { description = "OIDC shared secret."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "nextcloud"; restartUnits = [ "phpfpm-nextcloud.service" ]; }; }; }; secretForAuthelia = lib.mkOption { description = "OIDC shared secret. Content must be the same as `secretFile` option."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "authelia"; }; }; }; fallbackDefaultAuth = lib.mkOption { type = lib.types.bool; description = '' Fallback to normal Nextcloud auth if something goes wrong with the SSO app. Usually, you want to enable this to transfer existing users to LDAP and then you can disabled it. ''; default = false; }; }; }; }; memories = lib.mkOption { description = '' Memories App. [Nextcloud App Store](https://apps.nextcloud.com/apps/memories) Enabling this app will set up the Memories app and configure all its dependencies. On first install, you can either let the cron job index all images or you can run it manually with: ```nix nextcloud-occ memories:index ``` ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "Memories app."; vaapi = lib.mkOption { type = lib.types.bool; description = '' Enable VAAPI transcoding. Will make `nextcloud` user part of the `render` group to be able to access `/dev/dri/renderD128`. ''; default = false; }; photosPath = lib.mkOption { type = lib.types.str; description = '' Path where photos are stored in Nextcloud. ''; default = "/Photos"; }; }; }; }; recognize = lib.mkOption { description = '' Recognize App. [Nextcloud App Store](https://apps.nextcloud.com/apps/recognize) Enabling this app will set up the Recognize app and configure all its dependencies. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "Recognize app."; }; }; }; }; }; }; extraApps = lib.mkOption { type = lib.types.raw; description = '' Extra apps to install. Should be a function returning an `attrSet` of `appid` as keys to `packages` as values, like generated by `fetchNextcloudApp`. The appid must be identical to the `id` value in the apps' `appinfo/info.xml`. Search in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/master/pkgs/servers/nextcloud/packages) for the `NN.json` files for existing apps. You can still install apps through the appstore. ''; default = null; example = lib.literalExpression '' apps: { inherit (apps) mail calendar contact; phonetrack = pkgs.fetchNextcloudApp { name = "phonetrack"; sha256 = "0qf366vbahyl27p9mshfma1as4nvql6w75zy2zk5xwwbp343vsbc"; url = "https://gitlab.com/eneiluj/phonetrack-oc/-/wikis/uploads/931aaaf8dca24bf31a7e169a83c17235/phonetrack-0.6.9.tar.gz"; version = "0.6.9"; }; } ''; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "nextcloud"; sourceDirectories = [ cfg.dataDir ]; excludePatterns = [ ".rnd" ]; }; }; }; debug = lib.mkOption { type = lib.types.bool; description = "Enable more verbose logging."; default = false; example = true; }; tracing = lib.mkOption { type = lib.types.nullOr lib.types.str; description = '' Enable xdebug tracing. To trigger writing a trace to `/var/log/xdebug`, add a the following header: ``` XDEBUG_TRACE ``` The response will contain the following header: ``` x-xdebug-profile-filename /var/log/xdebug/cachegrind.out.63484 ``` ''; default = null; example = "debug_me"; }; autoDisableMaintenanceModeOnStart = lib.mkOption { type = lib.types.bool; default = true; description = '' Upon starting the service, disable maintenance mode if set. This is useful if a deploy failed and you try to redeploy. Note that even if the disabling of maintenance mode fails, SHB will still allow the startup to continue because there are valid reasons for maintenance mode to not be able to be lifted, like for example this is a brand new installation. ''; }; alwaysApplyExpensiveMigrations = lib.mkOption { type = lib.types.bool; default = true; description = '' Run `occ maintenance:repair --include-expensive` on service start. Larger instances should disable this and run the command at a convenient time but SHB assumes that it will not be the case for most users. Note that SHB will still allow the startup even if the repair failed. ''; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${fqdn}"; externalUrlText = "https://\${config.shb.nextcloud.subdomain}.\${config.shb.nextcloud.domain}"; internalUrl = "https://${fqdn}"; internalUrlText = "https://\${config.shb.nextcloud.subdomain}.\${config.shb.nextcloud.domain}"; }; }; }; }; config = lib.mkMerge [ (lib.mkIf cfg.enable { users.users = { nextcloud = { name = "nextcloud"; group = "nextcloud"; isSystemUser = true; }; }; # LDAP is manually configured through # https://github.com/lldap/lldap/blob/main/example_configs/nextcloud.md, see also # https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html # # Verify setup with: # - On admin page # - https://scan.nextcloud.com/ # - https://www.ssllabs.com/ssltest/ # As of writing this, we got no warning on admin page and A+ on both tests. # # Content-Security-Policy is hard. I spent so much trying to fix lingering issues with .js files # not loading to realize those scripts are inserted by extensions. Doh. services.nextcloud = { enable = true; package = nextcloudPkg.overrideAttrs (old: { patches = [ ../../patches/nextcloudexternalstorage.patch ]; }); datadir = cfg.dataDir; hostName = fqdn; nginx.hstsMaxAge = 31536000; # Needs > 1 year for https://hstspreload.org to be happy inherit (cfg) maxUploadSize; config = { dbtype = "pgsql"; adminuser = cfg.initialAdminUsername; adminpassFile = cfg.adminPass.result.path; }; database.createLocally = true; # Enable caching using redis https://nixos.wiki/wiki/Nextcloud#Caching. configureRedis = true; caching.apcu = false; # https://docs.nextcloud.com/server/26/admin_manual/configuration_server/caching_configuration.html caching.redis = true; # Adds appropriate nginx rewrite rules. webfinger = true; # Very important for a bunch of scripts to load correctly. Otherwise you get Content-Security-Policy errors. See https://docs.nextcloud.com/server/13/admin_manual/configuration_server/harden_server.html#enable-http-strict-transport-security https = !(isNull cfg.ssl); extraApps = if isNull cfg.extraApps then { } else cfg.extraApps nextcloudApps; extraAppsEnable = true; appstoreEnable = true; settings = let protocol = if !(isNull cfg.ssl) then "https" else "http"; in { "default_phone_region" = cfg.defaultPhoneRegion; "overwrite.cli.url" = "${protocol}://${fqdn}"; "overwritehost" = fqdnWithPort; # 'trusted_domains' needed otherwise we get this issue https://help.nextcloud.com/t/the-polling-url-does-not-start-with-https-despite-the-login-url-started-with-https/137576/2 # TODO: could instead set extraTrustedDomains "trusted_domains" = [ fqdn ]; "trusted_proxies" = [ "127.0.0.1" ]; # TODO: could instead set overwriteProtocol "overwriteprotocol" = protocol; # Needed if behind a reverse_proxy "overwritecondaddr" = ""; # We need to set it to empty otherwise overwriteprotocol does not work. "debug" = cfg.debug; "loglevel" = if !cfg.debug then 2 else 0; "filelocking.debug" = cfg.debug; # Use persistent SQL connections. "dbpersistent" = "true"; # https://help.nextcloud.com/t/very-slow-sync-for-small-files/11064/13 "chunkSize" = "5120MB"; }; phpOptions = { # The OPcache interned strings buffer is nearly full with 8, bump to 16. catch_workers_output = "yes"; display_errors = "stderr"; error_reporting = "E_ALL & ~E_DEPRECATED & ~E_STRICT"; expose_php = "Off"; "opcache.enable_cli" = "1"; "opcache.fast_shutdown" = "1"; "opcache.interned_strings_buffer" = "16"; "opcache.max_accelerated_files" = "10000"; "opcache.memory_consumption" = "128"; "opcache.revalidate_freq" = "1"; short_open_tag = "Off"; # https://docs.nextcloud.com/server/stable/admin_manual/configuration_files/big_file_upload_configuration.html#configuring-php # > Output Buffering must be turned off [...] or PHP will return memory-related errors. output_buffering = "Off"; # Needed to avoid corruption per https://docs.nextcloud.com/server/21/admin_manual/configuration_server/caching_configuration.html#id2 "redis.session.locking_enabled" = "1"; "redis.session.lock_retries" = "-1"; "redis.session.lock_wait_time" = "10000"; } // lib.optionalAttrs (!(isNull cfg.tracing)) { # "xdebug.remote_enable" = "on"; # "xdebug.remote_host" = "127.0.0.1"; # "xdebug.remote_port" = "9000"; # "xdebug.remote_handler" = "dbgp"; "xdebug.trigger_value" = cfg.tracing; "xdebug.mode" = "profile,trace"; "xdebug.output_dir" = "/var/log/xdebug"; "xdebug.start_with_request" = "trigger"; }; poolSettings = lib.mkIf (!(isNull cfg.phpFpmPoolSettings)) cfg.phpFpmPoolSettings; phpExtraExtensions = all: [ all.xdebug ]; }; services.nginx.virtualHosts.${fqdn} = { # listen = [ { addr = "0.0.0.0"; port = 443; } ]; forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; # From [1] this should fix downloading of big files. [2] seems to indicate that buffering # happens at multiple places anyway, so disabling one place should be okay. # [1]: https://help.nextcloud.com/t/download-aborts-after-time-or-large-file/25044/6 # [2]: https://stackoverflow.com/a/50891625/1013628 extraConfig = '' proxy_buffering off; ''; }; environment.systemPackages = [ # Needed for a few apps. Would be nice to avoid having to put that in the environment and instead override https://github.com/NixOS/nixpkgs/blob/261abe8a44a7e8392598d038d2e01f7b33cf26d0/nixos/modules/services/web-apps/nextcloud.nix#L1035 pkgs.ffmpeg-headless ]; services.postgresql.settings = lib.mkIf (!(isNull cfg.postgresSettings)) cfg.postgresSettings; systemd.services.phpfpm-nextcloud.preStart = '' mkdir -p /var/log/xdebug; chown -R nextcloud: /var/log/xdebug ''; systemd.services.phpfpm-nextcloud.requires = cfg.mountPointServices; systemd.services.phpfpm-nextcloud.after = cfg.mountPointServices; systemd.timers.nextcloud-cron.requires = cfg.mountPointServices; systemd.timers.nextcloud-cron.after = cfg.mountPointServices; # This is needed to be able to run the cron job before opening the app for the first time. # Otherwise the cron job fails while searching for this directory. systemd.services.nextcloud-setup.script = '' mkdir -p ${cfg.dataDir}/data/appdata_$(${occ} config:system:get instanceid)/theming/global ''; systemd.services.nextcloud-setup.requires = cfg.mountPointServices; systemd.services.nextcloud-setup.after = cfg.mountPointServices; }) (lib.mkIf (cfg.enable && cfg.phpFpmPrometheusExporter.enable) { services.prometheus.exporters.php-fpm = { enable = true; user = "nginx"; port = cfg.phpFpmPrometheusExporter.port; listenAddress = "127.0.0.1"; extraFlags = [ "--phpfpm.scrape-uri=tcp://127.0.0.1:${ toString (cfg.phpFpmPrometheusExporter.port - 1) }/status?full" ]; }; services.nextcloud = { poolSettings = { "pm.status_path" = "/status"; # Need to use TCP connection to get status. # I couldn't get PHP-FPM exporter to work with a unix socket. # # I also tried to server the status page at /status.php # but fcgi doesn't like the returned headers. "pm.status_listen" = "127.0.0.1:${toString (cfg.phpFpmPrometheusExporter.port - 1)}"; }; }; services.prometheus.scrapeConfigs = [ { job_name = "phpfpm-nextcloud"; static_configs = [ { targets = [ "127.0.0.1:${toString cfg.phpFpmPrometheusExporter.port}" ]; labels = { "hostname" = config.networking.hostName; "domain" = cfg.domain; }; } ]; } ]; }) (lib.mkIf (cfg.enable && cfg.apps.onlyoffice.enable) { assertions = [ { assertion = !(isNull cfg.apps.onlyoffice.jwtSecretFile); message = "Must set shb.nextcloud.apps.onlyoffice.jwtSecretFile."; } ]; services.nextcloud.extraApps = { inherit (nextcloudApps) onlyoffice; }; services.onlyoffice = { enable = true; hostname = "${cfg.apps.onlyoffice.subdomain}.${cfg.domain}"; port = 13444; postgresHost = "/run/postgresql"; jwtSecretFile = cfg.apps.onlyoffice.jwtSecretFile; }; services.nginx.virtualHosts."${cfg.apps.onlyoffice.subdomain}.${cfg.domain}" = { forceSSL = !(isNull cfg.ssl); sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; locations."/" = { extraConfig = '' allow ${cfg.apps.onlyoffice.localNetworkIPRange}; ''; }; }; }) (lib.mkIf (cfg.enable && cfg.apps.previewgenerator.enable) { services.nextcloud.extraApps = { inherit (nextcloudApps) previewgenerator; }; services.nextcloud.settings = { # List obtained from the admin panel of Memories app. enabledPreviewProviders = [ "OC\\Preview\\BMP" "OC\\Preview\\GIF" "OC\\Preview\\HEIC" "OC\\Preview\\Image" "OC\\Preview\\JPEG" "OC\\Preview\\Krita" "OC\\Preview\\MarkDown" "OC\\Preview\\Movie" "OC\\Preview\\MP3" "OC\\Preview\\OpenDocument" "OC\\Preview\\PNG" "OC\\Preview\\TXT" "OC\\Preview\\XBitmap" ]; }; # Values taken from # http://web.archive.org/web/20200513043150/https://ownyourbits.com/2019/06/29/understanding-and-improving-nextcloud-previews/ systemd.services.nextcloud-setup.script = lib.mkIf cfg.apps.previewgenerator.recommendedSettings '' ${occ} config:app:set previewgenerator squareSizes --value="32 256" ${occ} config:app:set previewgenerator widthSizes --value="256 384" ${occ} config:app:set previewgenerator heightSizes --value="256" ${occ} config:system:set preview_max_x --type integer --value 2048 ${occ} config:system:set preview_max_y --type integer --value 2048 ${occ} config:system:set jpeg_quality --value 60 ${occ} config:app:set preview jpeg_quality --value=60 ''; # Configured as defined in https://github.com/nextcloud/previewgenerator systemd.timers.nextcloud-cron-previewgenerator = { wantedBy = [ "timers.target" ]; requires = cfg.mountPointServices; after = [ "nextcloud-setup.service" ] ++ cfg.mountPointServices; timerConfig.OnBootSec = "10m"; timerConfig.OnUnitActiveSec = "10m"; timerConfig.Unit = "nextcloud-cron-previewgenerator.service"; }; systemd.services.nextcloud-cron-previewgenerator = { environment.NEXTCLOUD_CONFIG_DIR = "${config.services.nextcloud.datadir}/config"; serviceConfig.Type = "oneshot"; serviceConfig.ExecStart = let debug = if cfg.debug or cfg.apps.previewgenerator.debug then "-vvv" else ""; in "${occ} ${debug} preview:pre-generate"; }; }) (lib.mkIf (cfg.enable && cfg.apps.externalStorage.enable) { systemd.services.nextcloud-setup.script = '' ${occ} app:install files_external || : ${occ} app:enable files_external '' + lib.optionalString (cfg.apps.externalStorage.userLocalMount != null) ( let cfg' = cfg.apps.externalStorage.userLocalMount; jq = "${pkgs.jq}/bin/jq"; in # sh '' exists=$(${occ} files_external:list --output=json | ${jq} 'any(.[]; .mount_point == "${cfg'.mountName}" and .configuration.datadir == "${cfg'.directory}")') if [[ "$exists" == "false" ]]; then ${occ} files_external:create \ '${cfg'.mountName}' \ local \ null::null \ --config datadir='${cfg'.directory}' fi '' ); }) (lib.mkIf (cfg.enable && cfg.apps.ldap.enable) { systemd.services.nextcloud-setup.path = [ pkgs.jq ]; systemd.services.nextcloud-setup.script = let cfg' = cfg.apps.ldap; cID = "s" + toString cfg'.configID; in '' ${occ} app:install user_ldap || : ${occ} app:enable user_ldap ${occ} config:app:set user_ldap ${cID}ldap_configuration_active --value=0 ${occ} config:app:set user_ldap configuration_prefixes --value '["${cID}"]' # The following CLI commands follow # https://github.com/lldap/lldap/blob/main/example_configs/nextcloud.md#nextcloud-config--the-cli-way ${occ} ldap:set-config "${cID}" 'ldapHost' \ '${cfg'.host}' ${occ} ldap:set-config "${cID}" 'ldapPort' \ '${toString cfg'.port}' ${occ} ldap:set-config "${cID}" 'ldapAgentName' \ 'uid=${cfg'.adminName},ou=people,${cfg'.dcdomain}' ${occ} ldap:set-config "${cID}" 'ldapAgentPassword' \ "$(cat ${cfg'.adminPassword.result.path})" ${occ} ldap:set-config "${cID}" 'ldapBase' \ '${cfg'.dcdomain}' ${occ} ldap:set-config "${cID}" 'ldapBaseGroups' \ '${cfg'.dcdomain}' ${occ} ldap:set-config "${cID}" 'ldapBaseUsers' \ '${cfg'.dcdomain}' ${occ} ldap:set-config "${cID}" 'ldapEmailAttribute' \ 'mail' ${occ} ldap:set-config "${cID}" 'ldapGroupFilter' \ '(&(|(objectclass=groupOfUniqueNames))(|(cn=${cfg'.userGroup})))' ${occ} ldap:set-config "${cID}" 'ldapGroupFilterGroups' \ '${cfg'.userGroup}' ${occ} ldap:set-config "${cID}" 'ldapGroupFilterObjectclass' \ 'groupOfUniqueNames' ${occ} ldap:set-config "${cID}" 'ldapGroupMemberAssocAttr' \ 'uniqueMember' ${occ} ldap:set-config "${cID}" 'ldapLoginFilter' \ '(&(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))(|(uid=%uid)(|(mail=%uid)(objectclass=%uid))))' ${occ} ldap:set-config "${cID}" 'ldapLoginFilterAttributes' \ 'mail;objectclass' ${occ} ldap:set-config "${cID}" 'ldapUserDisplayName' \ 'givenname' ${occ} ldap:set-config "${cID}" 'ldapUserFilter' \ '(&(objectclass=person)(memberOf=cn=${cfg'.userGroup},ou=groups,${cfg'.dcdomain}))' ${occ} ldap:set-config "${cID}" 'ldapUserFilterMode' \ '1' ${occ} ldap:set-config "${cID}" 'ldapUserFilterObjectclass' \ 'person' # Makes the user_id used when creating a user through LDAP which means the ID used in # Nextcloud is compatible with the one returned by a (possibly added in the future) SSO # provider. ${occ} ldap:set-config "${cID}" 'ldapExpertUsernameAttr' \ 'uid' ${occ} ldap:test-config -- "${cID}" # Only one active at the same time ALL_CONFIG="$(${occ} ldap:show-config --output=json)" for configid in $(echo "$ALL_CONFIG" | jq --raw-output "keys[]"); do echo "Deactivating $configid" ${occ} ldap:set-config "$configid" 'ldapConfigurationActive' \ '0' done ${occ} ldap:set-config "${cID}" 'ldapConfigurationActive' \ '1' ''; }) ( let scopes = [ "openid" "profile" "email" "groups" "nextcloud_userinfo" ]; in lib.mkIf (cfg.enable && cfg.apps.sso.enable) { assertions = [ { assertion = cfg.ssl != null; message = "To integrate SSO, SSL must be enabled, set the shb.nextcloud.ssl option."; } ]; services.nextcloud.extraApps = { inherit (nextcloudApps) oidc_login; }; systemd.services.nextcloud-setup-pre = { wantedBy = [ "multi-user.target" ]; before = [ "nextcloud-setup.service" ]; serviceConfig.Type = "oneshot"; serviceConfig.User = "nextcloud"; script = '' mkdir -p ${cfg.dataDir}/config cat < "${cfg.dataDir}/config/secretFile" { "oidc_login_client_secret": "$(cat ${cfg.apps.sso.secret.result.path})" } EOF ''; }; services.nextcloud = { secretFile = "${cfg.dataDir}/config/secretFile"; # See all options at https://github.com/pulsejet/nextcloud-oidc-login # Other important url/links are: # ${fqdn}/.well-known/openid-configuration # https://www.authelia.com/reference/guides/attributes/#custom-attributes # https://github.com/lldap/lldap/blob/main/example_configs/nextcloud_oidc_authelia.md # https://www.authelia.com/integration/openid-connect/nextcloud/#authelia # https://www.openidconnect.net/ settings = { allow_user_to_change_display_name = false; lost_password_link = "disabled"; oidc_login_provider_url = ssoFqdnWithPort; oidc_login_client_id = cfg.apps.sso.clientID; # Automatically redirect the login page to the provider. oidc_login_auto_redirect = !cfg.apps.sso.fallbackDefaultAuth; # Authelia at least does not support this. oidc_login_end_session_redirect = false; # Redirect to this page after logging out the user oidc_login_logout_url = ssoFqdnWithPort; oidc_login_button_text = "Log in with ${cfg.apps.sso.provider}"; oidc_login_hide_password_form = false; # Now, Authelia provides the info using the UserInfo request. oidc_login_use_id_token = false; oidc_login_attributes = { id = "preferred_username"; name = "name"; mail = "email"; groups = "groups"; is_admin = "is_nextcloud_admin"; }; oidc_login_allowed_groups = [ cfg.apps.ldap.userGroup cfg.apps.sso.adminGroup ]; oidc_login_default_group = "oidc"; oidc_login_use_external_storage = false; oidc_login_scope = lib.concatStringsSep " " scopes; oidc_login_proxy_ldap = false; # Enable creation of users new to Nextcloud from OIDC login. A user may be known to the # IdP but not (yet) known to Nextcloud. This setting controls what to do in this case. # * 'true' (default): if the user authenticates to the IdP but is not known to Nextcloud, # then they will be returned to the login screen and not allowed entry; # * 'false': if the user authenticates but is not yet known to Nextcloud, then the user # will be automatically created; note that with this setting, you will be allowing (or # relying on) a third-party (the IdP) to create new users oidc_login_disable_registration = false; oidc_login_redir_fallback = cfg.apps.sso.fallbackDefaultAuth; # oidc_login_alt_login_page = "assets/login.php"; oidc_login_tls_verify = true; # If you get your groups from the oidc_login_attributes, you might want to create them if # they are not already existing, Default is `false`. This creates groups for all groups # the user is associated with in LDAP. It's too much. oidc_create_groups = false; oidc_login_webdav_enabled = false; oidc_login_password_authentication = false; oidc_login_public_key_caching_time = 86400; oidc_login_min_time_between_jwks_requests = 10; oidc_login_well_known_caching_time = 86400; # If true, nextcloud will download user avatars on login. This may lead to security issues # as the server does not control which URLs will be requested. Use with care. oidc_login_update_avatar = false; oidc_login_code_challenge_method = "S256"; }; }; shb.authelia.extraDefinitions = { user_attributes."is_nextcloud_admin".expression = ''type(groups) == list && "${cfg.apps.sso.adminGroup}" in groups''; }; shb.authelia.extraOidcClaimsPolicies."nextcloud_userinfo" = { custom_claims = { is_nextcloud_admin = { }; }; }; shb.authelia.extraOidcScopes."nextcloud_userinfo" = { claims = [ "is_nextcloud_admin" ]; }; shb.authelia.oidcClients = lib.mkIf (cfg.apps.sso.provider == "Authelia") [ { client_id = cfg.apps.sso.clientID; client_name = "Nextcloud"; client_secret.source = cfg.apps.sso.secretForAuthelia.result.path; claims_policy = "nextcloud_userinfo"; public = false; authorization_policy = cfg.apps.sso.authorization_policy; require_pkce = "true"; pkce_challenge_method = "S256"; redirect_uris = [ "${protocol}://${fqdnWithPort}/apps/oidc_login/oidc" ]; inherit scopes; response_types = [ "code" ]; grant_types = [ "authorization_code" ]; access_token_signed_response_alg = "none"; userinfo_signed_response_alg = "none"; token_endpoint_auth_method = "client_secret_basic"; } ]; } ) (lib.mkIf (cfg.enable && cfg.autoDisableMaintenanceModeOnStart) { systemd.services.nextcloud-setup.preStart = lib.mkBefore '' if [[ -e /var/lib/nextcloud/config/config.php ]]; then ${occ} maintenance:mode --no-interaction --quiet --off || true fi ''; }) (lib.mkIf (cfg.enable && cfg.alwaysApplyExpensiveMigrations) { systemd.services.nextcloud-setup.script = '' if [[ -e /var/lib/nextcloud/config/config.php ]]; then ${occ} maintenance:repair --include-expensive || true fi ''; }) # Great source of inspiration: # https://github.com/Shawn8901/nix-configuration/blob/538c18d9ecbf7c7e649b1540c0d40881bada6690/modules/nixos/private/nextcloud/memories.nix#L226 (lib.mkIf cfg.apps.memories.enable ( let cfg' = cfg.apps.memories; exiftool = pkgs.exiftool.overrideAttrs ( f: p: { version = "12.70"; src = pkgs.fetchurl { url = "https://exiftool.org/Image-ExifTool-12.70.tar.gz"; hash = "sha256-TLJSJEXMPj870TkExq6uraX8Wl4kmNerrSlX3LQsr/4="; }; } ); in { assertions = [ { assertion = true; message = "Memories app has an issue for now, see https://github.com/ibizaman/selfhostblocks/issues/476."; } ]; services.nextcloud.extraApps = { inherit (nextcloudApps) memories; }; systemd.services.nextcloud-cron = { # required for memories # see https://github.com/pulsejet/memories/blob/master/docs/troubleshooting.md#issues-with-nixos path = [ pkgs.perl ]; }; services.nextcloud = { # See all options at https://memories.gallery/system-config/ settings = { "memories.exiftool" = "${exiftool}/bin/exiftool"; "memories.exiftool_no_local" = false; "memories.index.mode" = "3"; "memories.index.path" = cfg'.photosPath; "memories.timeline.default_path" = cfg'.photosPath; "memories.vod.disable" = !cfg'.vaapi; "memories.vod.vaapi" = cfg'.vaapi; "memories.vod.ffmpeg" = "${pkgs.ffmpeg-headless}/bin/ffmpeg"; "memories.vod.ffprobe" = "${pkgs.ffmpeg-headless}/bin/ffprobe"; "memories.vod.use_transpose" = true; "memories.vod.use_transpose.force_sw" = cfg'.vaapi; # AMD and old Intel can't use hardware here. "memories.db.triggers.fcu" = true; "memories.readonly" = true; "preview_ffmpeg_path" = "${pkgs.ffmpeg-headless}/bin/ffmpeg"; }; }; systemd.services.phpfpm-nextcloud.serviceConfig = lib.mkIf cfg'.vaapi { DeviceAllow = [ "/dev/dri/renderD128 rwm" ]; PrivateDevices = lib.mkForce false; }; } )) (lib.mkIf cfg.apps.recognize.enable ( let cfg' = cfg.apps.recognize; in { services.nextcloud.extraApps = { inherit (nextcloudApps) recognize; }; systemd.services.nextcloud-setup.script = '' ${occ} config:app:set recognize nice_binary --value ${pkgs.coreutils}/bin/nice ${occ} config:app:set recognize node_binary --value ${pkgs.nodejs}/bin/node ${occ} config:app:set recognize faces.enabled --value true ${occ} config:app:set recognize faces.batchSize --value 50 ${occ} config:app:set recognize imagenet.enabled --value true ${occ} config:app:set recognize imagenet.batchSize --value 100 ${occ} config:app:set recognize landmarks.batchSize --value 100 ${occ} config:app:set recognize landmarks.enabled --value true ${occ} config:app:set recognize tensorflow.cores --value 1 ${occ} config:app:set recognize tensorflow.gpu --value false ${occ} config:app:set recognize tensorflow.purejs --value false ${occ} config:app:set recognize musicnn.enabled --value true ${occ} config:app:set recognize musicnn.batchSize --value 100 ''; } )) (lib.mkIf (cfg.enable && cfg.enableDashboard) { shb.monitoring.dashboards = [ ./nextcloud-server/dashboard/Nextcloud.json ]; }) ]; } ================================================ FILE: modules/services/open-webui/docs/default.md ================================================ # Open-WebUI Service {#services-open-webui} Defined in [`/modules/blocks/open-webui.nix`](@REPO@/modules/blocks/open-webui.nix), found in the `selfhostblocks.nixosModules.open-webui` module. See [the manual](usage.html#usage-flake) for how to import the module in your code. This service sets up [Open WebUI][] which provides a frontend to various LLMs. [Open WebUI]: https://docs.openwebui.com/ ## Features {#services-open-webui-features} - Telemetry disabled. - Skip onboarding through custom patch. - Declarative [LDAP](#services-open-webui-options-shb.open-webui.ldap) Configuration. - Needed LDAP groups are created automatically. - Declarative [SSO](#services-open-webui-options-shb.open-webui.sso) Configuration. - When SSO is enabled, login with user and password is disabled. - Registration is enabled through SSO. - Correct error message for unauthorized user through custom patch. - Access through [subdomain](#services-open-webui-options-shb.open-webui.subdomain) using reverse proxy. - Access through [HTTPS](#services-open-webui-options-shb.open-webui.ssl) using reverse proxy. - [Backup](#services-open-webui-options-shb.open-webui.sso) through the [backup block](./blocks-backup.html). - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-open-webui-usage-applicationdashboard) ## Usage {#services-open-webui-usage} ### Initial Configuration {#services-open-webui-usage-configuration} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ```nix { shb.open-webui = { enable = true; domain = "example.com"; subdomain = "open-webui"; ssl = config.shb.certs.certs.letsencrypt.${domain}; sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; sharedSecret.result = config.shb.sops.secret.oidcSecret.result; sharedSecretForAuthelia.result = config.shb.sops.secret.oidcAutheliaSecret.result; }; }; shb.sops.secret."open-webui/oidcSecret".request = config.shb.open-webui.sso.sharedSecret.request; shb.sops.secret."open-webui/oidcAutheliaSecret" = { request = config.shb.open-webui.sso.sharedSecretForAuthelia.request; settings.key = "open-webui/oidcSecret"; }; } ``` Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. The [user](#services-open-webui-options-shb.open-webui.ldap.userGroup) and [admin](#services-open-webui-options-shb.open-webui.ldap.adminGroup) LDAP groups are created automatically. ### Application Dashboard {#services-open-webui-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-open-webui-options-shb.open-webui.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Documents.services.OpenWebUI = { sortOrder = 1; dashboard.request = config.shb.home-assistant.dashboard.request; settings.icon = "sh-open-webui"; }; } ``` The icon needs to be set manually otherwise it is not displayed correctly. ## Integration with OLLAMA {#services-open-webui-ollama} Assuming ollama is enabled, it will be available on port `config.services.ollama.port`. The following snippet sets up acceleration using an AMD (i)GPU and loads some models. ```nix { services.ollama = { enable = true; # https://wiki.nixos.org/wiki/Ollama#AMD_GPU_with_open_source_driver acceleration = "rocm"; # https://ollama.com/library loadModels = [ "deepseek-r1:1.5b" "llama3.2:3b" "llava:7b" "mxbai-embed-large:335m" "nomic-embed-text:v1.5" ]; }; } ``` Integrating with the ollama service is done with: ```nix { shb.open-webui = { environment.OLLAMA_BASE_URL = "http://127.0.0.1:${toString config.services.ollama.port}"; }; } ``` ## Backup {#services-open-webui-usage-backup} Backing up Open-Webui using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."open-webui" = { request = config.shb.open-webui.backup; settings = { enable = true; }; }; ``` The name `"open-webui"` in the `instances` can be anything. The `config.shb.open-webui.backup` option provides what directories to backup. You can define any number of Restic instances to backup Open WebUI multiple times. ## Options Reference {#services-open-webui-options} ```{=include=} options id-prefix: services-open-webui-options- list-id: selfhostblocks-services-open-webui-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/open-webui.nix ================================================ { config, lib, pkgs, shb, ... }: let cfg = config.shb.open-webui; roleClaim = "openwebui_groups"; oauthScopes = [ "openid" "email" "profile" "groups" "${roleClaim}" ]; in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ]; options.shb.open-webui = { enable = lib.mkEnableOption "the Open-WebUI service"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Open-WebUI will be served."; default = "open-webui"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which Open-WebUI will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; port = lib.mkOption { type = lib.types.port; description = "Port Open-WebUI listens to incoming requests."; default = 12444; }; environment = lib.mkOption { type = lib.types.attrsOf lib.types.str; description = "Extra environment variables. See https://docs.openwebui.com/getting-started/env-configuration"; default = { }; example = '' { WEBUI_NAME = "SelfHostBlocks"; OLLAMA_BASE_URL = "http://127.0.0.1:''${toString config.services.ollama.port}"; RAG_EMBEDDING_MODEL = "nomic-embed-text:v1.5"; ENABLE_OPENAI_API = "True"; OPENAI_API_BASE_URL = "http://127.0.0.1:''${toString config.services.llama-cpp.port}"; ENABLE_WEB_SEARCH = "True"; RAG_EMBEDDING_ENGINE = "openai"; } ''; }; ldap = lib.mkOption { description = '' Setup LDAP integration. ''; default = { }; type = lib.types.submodule { options = { userGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to be able to login."; default = "open-webui_user"; }; adminGroup = lib.mkOption { type = lib.types.str; description = "Group users must belong to to have administrator privileges."; default = "open-webui_admin"; }; }; }; }; sso = lib.mkOption { description = '' Setup SSO integration. ''; default = { }; type = lib.types.submodule { options = { enable = lib.mkEnableOption "SSO integration."; authEndpoint = lib.mkOption { type = lib.types.str; description = "Endpoint to the SSO provider."; example = "https://authelia.example.com"; }; clientID = lib.mkOption { type = lib.types.str; description = "Client ID for the OIDC endpoint."; default = "open-webui"; }; authorization_policy = lib.mkOption { type = lib.types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; sharedSecret = lib.mkOption { description = "OIDC shared secret for Open-WebUI."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { owner = "open-webui"; restartUnits = [ "open-webui.service" ]; }; }; }; sharedSecretForAuthelia = lib.mkOption { description = "OIDC shared secret for Authelia. Must be the same as `sharedSecret`"; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; ownerText = "config.shb.authelia.autheliaUser"; owner = config.shb.authelia.autheliaUser; }; }; }; }; }; }; backup = lib.mkOption { description = '' Backup state directory. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "open-webui"; sourceDirectories = [ config.services.open-webui.stateDir ]; sourceDirectoriesText = "[ config.services.open-webui.stateDir ]"; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.open-webui.subdomain}.\${config.shb.open-webui.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.port}"; }; }; }; }; config = ( lib.mkMerge [ (lib.mkIf cfg.enable { users.users.open-webui = { isSystemUser = true; group = "open-webui"; }; users.groups.open-webui = { }; services.open-webui = { enable = true; host = "127.0.0.1"; inherit (cfg) port; environment = { WEBUI_URL = "https://${cfg.subdomain}.${cfg.domain}"; ENABLE_PERSISTENT_CONFIG = "False"; ANONYMIZED_TELEMETRY = "False"; DO_NOT_TRACK = "True"; SCARF_NO_ANALYTICS = "True"; ENABLE_VERSION_UPDATE_CHECK = "False"; } // cfg.environment; }; systemd.services.open-webui.path = [ pkgs.ffmpeg-headless ]; shb.nginx.vhosts = [ { inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString cfg.port}/"; extraConfig = '' proxy_read_timeout 300s; proxy_send_timeout 300s; ''; } ]; }) (lib.mkIf (cfg.enable && cfg.sso.enable) { shb.lldap.ensureGroups = { ${cfg.ldap.userGroup} = { }; ${cfg.ldap.adminGroup} = { }; }; services.open-webui = { package = pkgs.open-webui.overrideAttrs (finalAttrs: { patches = [ ../../patches/0001-selfhostblocks-never-onboard.patch ]; }); environment = { ENABLE_SIGNUP = "False"; WEBUI_AUTH = "True"; ENABLE_FORWARD_USER_INFO_HEADERS = "True"; ENABLE_OAUTH_SIGNUP = "True"; OAUTH_UPDATE_PICTURE_ON_LOGIN = "True"; OAUTH_CLIENT_ID = cfg.sso.clientID; OPENID_PROVIDER_URL = "${cfg.sso.authEndpoint}/.well-known/openid-configuration"; OAUTH_PROVIDER_NAME = "Single Sign-On"; OAUTH_USERNAME_CLAIM = "preferred_username"; ENABLE_OAUTH_ROLE_MANAGEMENT = "True"; OAUTH_ALLOWED_ROLES = "user,admin"; OAUTH_ADMIN_ROLES = "admin"; OAUTH_ROLES_CLAIM = roleClaim; OAUTH_SCOPES = lib.concatStringsSep " " oauthScopes; }; }; shb.authelia.extraDefinitions = { user_attributes.${roleClaim}.expression = ''"${cfg.ldap.adminGroup}" in groups ? ["admin"] : ("${cfg.ldap.userGroup}" in groups ? ["user"] : [""])''; }; shb.authelia.extraOidcClaimsPolicies.${roleClaim} = { custom_claims = { "${roleClaim}" = { }; }; }; shb.authelia.extraOidcScopes."${roleClaim}" = { claims = [ "${roleClaim}" ]; }; shb.authelia.oidcClients = [ { client_id = cfg.sso.clientID; client_name = "Open WebUI"; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; claims_policy = "${roleClaim}"; public = false; authorization_policy = cfg.sso.authorization_policy; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/oauth/oidc/callback" ]; scopes = oauthScopes; } ]; systemd.services.open-webui.serviceConfig.EnvironmentFile = "/run/open-webui/secrets.env"; systemd.tmpfiles.rules = [ "d '/run/open-webui' 0750 root root - -" ]; systemd.services.open-webui-pre = { script = shb.replaceSecrets { userConfig = { OAUTH_CLIENT_SECRET.source = cfg.sso.sharedSecret.result.path; }; resultPath = "/run/open-webui/secrets.env"; generator = shb.toEnvVar; }; serviceConfig.Type = "oneshot"; wantedBy = [ "multi-user.target" ]; before = [ "open-webui.service" ]; requiredBy = [ "open-webui.service" ]; }; }) ] ); } ================================================ FILE: modules/services/paperless.nix ================================================ { config, pkgs, lib, shb, ... }: let cfg = config.shb.paperless; dataFolder = cfg.dataDir; fqdn = "${cfg.subdomain}.${cfg.domain}"; protocol = if !(isNull cfg.ssl) then "https" else "http"; ssoFqdnWithPort = if isNull cfg.sso.port then cfg.sso.endpoint else "${cfg.sso.endpoint}:${toString cfg.sso.port}"; ssoClientSettings = { openid_connect = { SCOPE = [ "openid" "profile" "email" "groups" ]; OAUTH_PKCE_ENABLED = true; APPS = [ { provider_id = "${cfg.sso.provider}"; name = "${cfg.sso.provider}"; client_id = "${cfg.sso.clientID}"; secret = "%SECRET_CLIENT_SECRET_PLACEHOLDER%"; settings = { server_url = ssoFqdnWithPort; token_auth_method = "client_secret_basic"; }; } ]; }; }; ssoClientSettingsFile = pkgs.writeText "paperless-sso-client.env" '' PAPERLESS_SOCIALACCOUNT_PROVIDERS=${builtins.toJSON ssoClientSettings} ''; replacements = [ { # Note: replaceSecretsScript prepends '%SECRET_' and appends '%' # when doing the replacement name = [ "CLIENT_SECRET_PLACEHOLDER" ]; source = cfg.sso.sharedSecret.result.path; } ]; replaceSecretsScript = shb.replaceSecretsScript { file = ssoClientSettingsFile; resultPath = "/run/paperless/paperless-sso-client.env"; inherit replacements; user = "paperless"; }; inherit (lib) mkEnableOption mkIf lists mkOption ; inherit (lib.types) attrsOf bool enum listOf nullOr port submodule str path ; in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ]; options.shb.paperless = { enable = mkEnableOption "selfhostblocks.paperless"; subdomain = mkOption { type = str; description = '' Subdomain under which paperless will be served. ``` . ``` ''; example = "photos"; }; domain = mkOption { description = '' Domain under which paperless is served. ``` . ``` ''; type = str; example = "example.com"; }; port = mkOption { description = '' Port under which paperless will listen. ''; type = port; default = 28981; }; ssl = mkOption { description = "Path to SSL files"; type = nullOr shb.contracts.ssl.certs; default = null; }; dataDir = mkOption { description = "Directory where paperless will store data files."; type = str; default = "/var/lib/paperless"; }; mediaDir = mkOption { description = "Directory where paperless will store documents."; type = str; defaultText = lib.literalExpression ''"''${dataDir}/media"''; default = "${cfg.dataDir}/media"; }; consumptionDir = mkOption { description = "Directory from which new documents are imported."; type = str; defaultText = lib.literalExpression ''"''${dataDir}/consume"''; default = "${cfg.dataDir}/consume"; }; configureTika = lib.mkOption { type = lib.types.bool; default = false; description = '' Whether to configure Tika and Gotenberg to process Office and e-mail files with OCR. ''; }; adminPassword = mkOption { description = "Secret containing the superuser (admin) password."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "paperless"; group = "paperless"; restartUnits = [ "paperless-server.service" ]; }; }; }; settings = lib.mkOption { type = lib.types.submodule { freeformType = with lib.types; attrsOf ( let typeList = [ bool float int str path package ]; in oneOf ( typeList ++ [ (listOf (oneOf typeList)) (attrsOf (oneOf typeList)) ] ) ); }; default = { }; description = '' Extra paperless config options. See [the documentation](https://docs.paperless-ngx.com/configuration/) for available options. Note that some settings such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values. Settings declared as lists or attrsets will automatically be serialised into JSON strings for your convenience. ''; example = { PAPERLESS_OCR_LANGUAGE = "deu+eng"; PAPERLESS_CONSUMER_IGNORE_PATTERN = [ ".DS_STORE/*" "desktop.ini" ]; PAPERLESS_OCR_USER_ARGS = { optimize = 1; pdfa_image_compression = "lossless"; }; }; }; mount = mkOption { type = shb.contracts.mount; description = '' Mount configuration. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."paperless" = { poolName = "root"; } // config.shb.paperless.mount; ``` ''; readOnly = true; default = { path = dataFolder; }; }; backup = mkOption { description = '' Backup configuration for paperless media files and database. ''; default = { }; type = submodule { options = shb.contracts.backup.mkRequester { user = "paperless"; sourceDirectories = [ dataFolder ]; excludePatterns = [ ]; }; }; }; sso = mkOption { description = '' Setup SSO integration. ''; default = { }; type = submodule { options = { enable = mkEnableOption "SSO integration."; provider = mkOption { type = enum [ "Authelia" "Keycloak" "Generic" ]; description = "OIDC provider name, used for display."; default = "Authelia"; }; endpoint = mkOption { type = str; description = "OIDC endpoint for SSO."; example = "https://authelia.example.com"; }; clientID = mkOption { type = str; description = "Client ID for the OIDC endpoint."; default = "paperless"; }; adminUserGroup = lib.mkOption { type = lib.types.str; description = "OIDC admin group"; default = "paperless_admin"; }; userGroup = lib.mkOption { type = lib.types.str; description = "OIDC user group"; default = "paperless_user"; }; port = mkOption { description = "If given, adds a port to the endpoint."; type = nullOr port; default = null; }; autoRegister = mkOption { type = bool; description = "Automatically register new users from SSO provider."; default = true; }; autoLaunch = mkOption { type = bool; description = "Automatically redirect to SSO provider."; default = true; }; passwordLogin = mkOption { type = bool; description = "Enable password login."; default = true; }; sharedSecret = mkOption { description = "OIDC shared secret for paperless."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "paperless"; group = "paperless"; restartUnits = [ "paperless-server.service" ]; }; }; }; sharedSecretForAuthelia = mkOption { description = "OIDC shared secret for Authelia. Content must be the same as `sharedSecret` option."; type = submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "authelia"; }; }; default = null; }; authorization_policy = mkOption { type = enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.paperless.subdomain}.\${config.shb.paperless.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.port}"; }; }; }; }; config = mkIf cfg.enable { assertions = [ { assertion = !(isNull cfg.ssl) -> !(isNull cfg.ssl.paths.cert) && !(isNull cfg.ssl.paths.key); message = "SSL is enabled for paperless but no cert or key is provided."; } { assertion = cfg.sso.enable -> cfg.ssl != null; message = "To integrate SSO, SSL must be enabled, set the shb.paperless.ssl option."; } ]; # Configure paperless service services.paperless = { enable = true; address = "127.0.0.1"; port = cfg.port; consumptionDirIsPublic = true; dataDir = cfg.dataDir; mediaDir = cfg.mediaDir; consumptionDir = cfg.consumptionDir; configureTika = cfg.configureTika; settings = { PAPERLESS_URL = "${protocol}://${fqdn}"; } // cfg.settings // lib.optionalAttrs (cfg.sso.enable) { PAPERLESS_APPS = "allauth.socialaccount.providers.openid_connect"; PAPERLESS_SOCIAL_AUTO_SIGNUP = cfg.sso.autoRegister; PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS = true; PAPERLESS_DISABLE_REGULAR_LOGIN = !cfg.sso.passwordLogin; }; } // lib.optionalAttrs (cfg.sso.enable) { environmentFile = "/run/paperless/paperless-sso-client.env"; }; # Database defaults to local sqlite systemd.tmpfiles.rules = [ "d ${cfg.dataDir} 0700 paperless paperless" "d ${cfg.consumptionDir} 0700 paperless paperless" "d ${cfg.mediaDir} 0700 paperless paperless" ] ++ lib.optionals cfg.sso.enable [ "d '/run/paperless' 0750 root root - -" ]; systemd.services.paperless-pre = lib.mkIf cfg.sso.enable { script = replaceSecretsScript; serviceConfig.Type = "oneshot"; wantedBy = [ "multi-user.target" ]; before = [ "paperless-scheduler.service" ]; requiredBy = [ "paperless-scheduler.service" ]; }; shb.nginx.vhosts = [ { inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString cfg.port}"; autheliaRules = lib.mkIf (cfg.sso.enable) [ { domain = fqdn; policy = cfg.sso.authorization_policy; subject = [ "group:paperless_user" "group:paperless_admin" ]; } ]; authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint; extraConfig = '' # See https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx proxy_redirect off; proxy_set_header X-Forwarded-Host $server_name; add_header Referrer-Policy "strict-origin-when-cross-origin"; ''; } ]; # Allow large uploads services.nginx.virtualHosts."${fqdn}".extraConfig = '' client_max_body_size 500M; ''; shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == "Authelia") [ { client_id = cfg.sso.clientID; client_name = "paperless"; client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; public = false; authorization_policy = cfg.sso.authorization_policy; token_endpoint_auth_method = "client_secret_basic"; redirect_uris = [ "${protocol}://${fqdn}/accounts/oidc/${cfg.sso.provider}/login/callback/" ]; } ]; }; } ================================================ FILE: modules/services/pinchflat/docs/default.md ================================================ # Pinchflat Service {#services-pinchflat} Defined in [`/modules/services/pinchflat.nix`](@REPO@/modules/services/pinchflat.nix). This NixOS module is a service that sets up a [Pinchflat](https://github.com/kieraneglin/pinchflat) instance. Compared to the stock module from nixpkgs, this one sets up, in a fully declarative manner, LDAP and SSO integration and has a nicer option for secrets. ## Features {#services-pinchflat-features} - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. [Manual](#services-pinchflat-usage-applicationdashboard) ## Usage {#services-pinchflat-usage} ### Initial Configuration {#services-pinchflat-usage-configuration} The following snippet assumes a few blocks have been setup already: - the [secrets block](usage.html#usage-secrets) with SOPS, - the [`shb.ssl` block](blocks-ssl.html#usage), - the [`shb.lldap` block](blocks-lldap.html#blocks-lldap-global-setup). - the [`shb.authelia` block](blocks-authelia.html#blocks-sso-global-setup). ```nix shb.pinchflat = { enable = true; secretKeyBase.result = config.shb.sops.secret."pinchflat/secretKeyBase".result; timeZone = "Europe/Brussels"; mediaDir = "/srv/pinchflat"; domain = "example.com"; subdomain = "pinchflat"; ssl = config.shb.certs.certs.letsencrypt.${domain}; ldap = { enable = true; }; sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; shb.sops.secret."pinchflat/secretKeyBase".request = config.shb.pinchflat.secretKeyBase.request; ``` Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. The [user](#services-pinchflat-options-shb.pinchflat.ldap.userGroup) LDAP group is created automatically. ### Backup {#services-pinchflat-usage-backup} Backing up Pinchflat using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."pinchflat" = { request = config.shb.pinchflat.backup; settings = { enable = true; }; }; ``` The name `"pinchflat"` in the `instances` can be anything. The `config.shb.pinchflat.backup` option provides what directories to backup. You can define any number of Restic instances to backup Pinchflat multiple times. ### Application Dashboard {#services-pinchflat-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-pinchflat-options-shb.pinchflat.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Media.services.Pinchflat = { sortOrder = 2; dashboard.request = config.shb.pinchflat.dashboard.request; }; } ``` ## Options Reference {#services-pinchflat-options} ```{=include=} options id-prefix: services-pinchflat-options- list-id: selfhostblocks-service-pinchflat-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/pinchflat.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.pinchflat; inherit (lib) types; in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ]; options.shb.pinchflat = { enable = lib.mkEnableOption "the Pinchflat service."; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Pinchflat will be served."; default = "pinchflat"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which Pinchflat will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; port = lib.mkOption { type = lib.types.port; description = "Port Pinchflat listens to incoming requests."; default = 8945; }; secretKeyBase = lib.mkOption { description = '' Used to sign/encrypt cookies and other secrets. Make sure the secret is at least 64 characters long. ''; type = types.submodule { options = shb.contracts.secret.mkRequester { restartUnits = [ "pinchflat.service" ]; }; }; }; mediaDir = lib.mkOption { description = "Path where videos are stored."; type = lib.types.str; }; timeZone = lib.mkOption { type = lib.types.oneOf [ lib.types.str shb.secretFileType ]; description = "Timezone of this instance."; example = "America/Los_Angeles"; }; ldap = lib.mkOption { description = '' Setup LDAP integration. ''; default = { }; type = types.submodule { options = { enable = lib.mkEnableOption "LDAP integration." // { default = cfg.sso.enable; }; userGroup = lib.mkOption { type = types.str; description = "Group users must belong to be able to login."; default = "pinchflat_user"; }; }; }; }; sso = lib.mkOption { description = '' Setup SSO integration. ''; default = { }; type = types.submodule { options = { enable = lib.mkEnableOption "SSO integration."; authEndpoint = lib.mkOption { type = lib.types.str; description = "Endpoint to the SSO provider."; example = "https://authelia.example.com"; }; authorization_policy = lib.mkOption { type = types.enum [ "one_factor" "two_factor" ]; description = "Require one factor (password) or two factor (device) authentication."; default = "one_factor"; }; }; }; }; backup = lib.mkOption { description = '' Backup media directory `shb.mediaDir`. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "pinchflat"; sourceDirectories = [ cfg.mediaDir ]; sourceDirectoriesText = "[ config.shb.pinchflat.mediaDir ]"; }; }; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.pinchflat.subdomain}.\${config.shb.pinchflat.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.port}"; }; }; }; }; config = lib.mkIf cfg.enable { systemd.tmpfiles.rules = [ "d '/run/pinchflat' 0750 root root - -" ]; # Pinchflat relies on the global value so for now this is the only way to pass the option in. time.timeZone = lib.mkDefault cfg.timeZone; services.pinchflat = { inherit (cfg) enable port mediaDir; secretsFile = "/run/pinchflat/secrets.env"; extraConfig = { ENABLE_PROMETHEUS = true; # TZ = "as"; # I consider where you live to be sensible so it should be passed as a secret. }; }; # This should be using a contract instead of setting the option directly. shb.lldap = lib.mkIf config.shb.lldap.enable { ensureGroups = { ${cfg.ldap.userGroup} = { }; }; }; systemd.services.pinchflat-pre = { script = shb.replaceSecrets { userConfig = { SECRET_KEY_BASE.source = cfg.secretKeyBase.result.path; # TZ = cfg.secretKeyBase.result.path; # Uncomment when PR is merged. }; resultPath = "/run/pinchflat/secrets.env"; generator = shb.toEnvVar; }; serviceConfig.Type = "oneshot"; wantedBy = [ "multi-user.target" ]; before = [ "pinchflat.service" ]; requiredBy = [ "pinchflat.service" ]; }; shb.nginx.vhosts = [ ( { inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString cfg.port}"; autheliaRules = lib.optionals (cfg.sso.enable) [ { domain = "${cfg.subdomain}.${cfg.domain}"; policy = cfg.sso.authorization_policy; subject = [ "group:${cfg.ldap.userGroup}" ]; } ]; } // lib.optionalAttrs cfg.sso.enable { inherit (cfg.sso) authEndpoint; } ) ]; services.prometheus.scrapeConfigs = [ { job_name = "pinchflat"; static_configs = [ { targets = [ "127.0.0.1:${toString cfg.port}" ]; labels = { "hostname" = config.networking.hostName; "domain" = cfg.domain; }; } ]; } ]; }; } ================================================ FILE: modules/services/vaultwarden/docs/default.md ================================================ # Vaultwarden Service {#services-vaultwarden} Defined in [`/modules/services/vaultwarden.nix`](@REPO@/modules/services/vaultwarden.nix). This NixOS module is a service that sets up a [Vaultwarden Server](https://github.com/dani-garcia/vaultwarden). ## Features {#services-vaultwarden-features} - Access through subdomain using reverse proxy. - Access through HTTPS using reverse proxy. - Automatic setup of Redis database for caching. - Backup of the data directory through the [backup contract](./contracts-backup.html). - [Integration Tests](@REPO@/test/services/vaultwarden.nix) - Tests /admin can only be accessed when authenticated with SSO. - Integration with the [dashboard contract](contracts-dashboard.html) for displaying user facing application in a dashboard. ## Usage {#services-vaultwarden-usage} ### Initial Configuration {#services-vaultwarden-usage-configuration} The following snippet enables Vaultwarden and makes it available under the `vaultwarden.example.com` endpoint. ```nix shb.vaultwarden = { enable = true; domain = "example.com"; subdomain = "vaultwarden"; port = 8222; databasePassword.result = config.shb.sops.secret."vaultwarden/db".result; smtp = { host = "smtp.eu.mailgun.org"; port = 587; username = "postmaster@mg.${domain}"; from_address = "authelia@${domain}"; passwordFile = config.sops.secrets."vaultwarden/smtp".path; }; }; shb.sops.secret."vaultwarden/db".request = config.shb.vaultwarden.databasePassword.request; shb.sops.secret."vaultwarden/smtp".request = config.shb.vaultwarden.smtp.password.request; ``` This assumes secrets are setup with SOPS as mentioned in [the secrets setup section](usage.html#usage-secrets) of the manual. Secrets can be randomly generated with `nix run nixpkgs#openssl -- rand -hex 64`. The SMTP configuration is needed to invite users to Vaultwarden. ### HTTPS {#services-vaultwarden-usage-https} If the `shb.ssl` block is used (see [manual](blocks-ssl.html#usage) on how to set it up), the instance will be reachable at `https://vaultwarden.example.com`. Here is an example with Let's Encrypt certificates, validated using the HTTP method: ```nix shb.certs.certs.letsencrypt."example.com" = { domain = "example.com"; group = "nginx"; reloadServices = [ "nginx.service" ]; adminEmail = "myemail@mydomain.com"; }; ``` Then you can tell Vaultwarden to use those certificates. ```nix shb.certs.certs.letsencrypt."example.com".extraDomains = [ "vaultwarden.example.com" ]; shb.forgejo = { ssl = config.shb.certs.certs.letsencrypt."example.com"; }; ``` ### SSO {#services-vaultwarden-usage-sso} To protect the `/admin` endpoint and avoid needing a secret passphrase for it, we can use SSO. We will use the [SSO block][] provided by Self Host Blocks. Assuming it [has been set already][SSO block setup], add the following configuration: [SSO block]: blocks-sso.html [SSO block setup]: blocks-sso.html#blocks-sso-global-setup ```nix shb.vaultwarden.authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; ``` Now, go to the LDAP server at `https://ldap.example.com`, create the `vaultwarden_admin` group and add a user to that group. When that's done, go back to the Vaultwarden server at `https://vaultwarden.example.com/admin` and login with that user. ### ZFS {#services-vaultwarden-zfs} Integration with the ZFS block allows to automatically create the relevant datasets. ```nix shb.zfs.datasets."vaultwarden" = config.shb.vaultwarden.mount; shb.zfs.datasets."postgresql".path = "/var/lib/postgresql"; ``` ### Backup {#services-vaultwarden-backup} Backing up Vaultwarden using the [Restic block](blocks-restic.html) is done like so: ```nix shb.restic.instances."vaultwarden" = { request = config.shb.vaultwarden.backup; settings = { enable = true; }; }; ``` The name `"vaultwarden"` in the `instances` can be anything. The `config.shb.vaultwarden.backup` option provides what directories to backup. You can define any number of Restic instances to backup Vaultwarden multiple times. ### Application Dashboard {#services-vaultwarden-usage-applicationdashboard} Integration with the [dashboard contract](contracts-dashboard.html) is provided by the [dashboard option](#services-vaultwarden-options-shb.vaultwarden.dashboard). For example using the [Homepage](services-homepage.html) service: ```nix { shb.homepage.servicesGroups.Documents.services.Vaultwarden = { sortOrder = 10; dashboard.request = config.shb.vaultwarden.dashboard.request; }; } ``` ## Maintenance {#services-vaultwarden-maintenance} No command-line tool is provided to administer Vaultwarden. Instead, the admin section can be found at the `/admin` endpoint. ## Debug {#services-vaultwarden-debug} In case of an issue, check the logs of the `vaultwarden.service` systemd service. Enable verbose logging by setting the `shb.vaultwarden.debug` boolean to `true`. Access the database with `sudo -u vaultwarden psql`. ## Options Reference {#services-vaultwarden-options} ```{=include=} options id-prefix: services-vaultwarden-options- list-id: selfhostblocks-vaultwarden-options source: @OPTIONS_JSON@ ``` ================================================ FILE: modules/services/vaultwarden.nix ================================================ { config, lib, shb, ... }: let cfg = config.shb.vaultwarden; fqdn = "${cfg.subdomain}.${cfg.domain}"; dataFolder = if lib.versionOlder (config.system.stateVersion or "24.11") "24.11" then "/var/lib/bitwarden_rs" else "/var/lib/vaultwarden"; in { imports = [ ../../lib/module.nix ../blocks/nginx.nix ]; options.shb.vaultwarden = { enable = lib.mkEnableOption "selfhostblocks.vaultwarden"; subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under which Authelia will be served."; example = "ha"; }; domain = lib.mkOption { type = lib.types.str; description = "domain under which Authelia will be served."; example = "mydomain.com"; }; ssl = lib.mkOption { description = "Path to SSL files"; type = lib.types.nullOr shb.contracts.ssl.certs; default = null; }; port = lib.mkOption { type = lib.types.port; description = "Port on which vaultwarden service listens."; default = 8222; }; authEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "OIDC endpoint for SSO"; default = null; example = "https://authelia.example.com"; }; databasePassword = lib.mkOption { description = "File containing the Vaultwarden database password."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0440"; owner = "vaultwarden"; group = "postgres"; restartUnits = [ "vaultwarden.service" "postgresql.service" ]; }; }; }; smtp = lib.mkOption { description = "SMTP options."; default = null; type = lib.types.nullOr ( lib.types.submodule { options = { from_address = lib.mkOption { type = lib.types.str; description = "SMTP address from which the emails originate."; example = "vaultwarden@mydomain.com"; }; from_name = lib.mkOption { type = lib.types.str; description = "SMTP name from which the emails originate."; default = "Vaultwarden"; }; host = lib.mkOption { type = lib.types.str; description = "SMTP host to send the emails to."; }; security = lib.mkOption { type = lib.types.enum [ "starttls" "force_tls" "off" ]; description = "Security expected by SMTP host."; default = "starttls"; }; port = lib.mkOption { type = lib.types.port; description = "SMTP port to send the emails to."; default = 25; }; username = lib.mkOption { type = lib.types.str; description = "Username to connect to the SMTP host."; }; auth_mechanism = lib.mkOption { type = lib.types.enum [ "Login" ]; description = "Auth mechanism."; default = "Login"; }; password = lib.mkOption { description = "File containing the password to connect to the SMTP host."; type = lib.types.submodule { options = shb.contracts.secret.mkRequester { mode = "0400"; owner = "vaultwarden"; restartUnits = [ "vaultwarden.service" ]; }; }; }; }; } ); }; mount = lib.mkOption { type = shb.contracts.mount; description = '' Mount configuration. This is an output option. Use it to initialize a block implementing the "mount" contract. For example, with a zfs dataset: ``` shb.zfs.datasets."vaultwarden" = { poolName = "root"; } // config.shb.vaultwarden.mount; ``` ''; readOnly = true; default = { path = dataFolder; }; }; backup = lib.mkOption { description = '' Backup configuration. ''; default = { }; type = lib.types.submodule { options = shb.contracts.backup.mkRequester { user = "vaultwarden"; sourceDirectories = [ dataFolder ]; }; }; }; debug = lib.mkOption { type = lib.types.bool; description = "Set to true to enable debug logging."; default = false; example = true; }; dashboard = lib.mkOption { description = '' Dashboard contract consumer ''; default = { }; type = lib.types.submodule { options = shb.contracts.dashboard.mkRequester { externalUrl = "https://${cfg.subdomain}.${cfg.domain}"; externalUrlText = "https://\${config.shb.vaultwarden.subdomain}.\${config.shb.vaultwarden.domain}"; internalUrl = "http://127.0.0.1:${toString cfg.port}"; }; }; }; }; config = lib.mkIf cfg.enable { services.vaultwarden = { enable = true; dbBackend = "postgresql"; config = { IP_HEADER = "X-Real-IP"; SIGNUPS_ALLOWED = false; # Disabled because the /admin path is protected by SSO DISABLE_ADMIN_TOKEN = true; INVITATIONS_ALLOWED = true; DOMAIN = "https://${fqdn}"; USE_SYSLOG = true; EXTENDED_LOGGING = cfg.debug; LOG_LEVEL = if cfg.debug then "trace" else "info"; ROCKET_LOG = if cfg.debug then "trace" else "info"; ROCKET_ADDRESS = "127.0.0.1"; ROCKET_PORT = cfg.port; } // lib.optionalAttrs (cfg.smtp != null) { SMTP_FROM = cfg.smtp.from_address; SMTP_FROM_NAME = cfg.smtp.from_name; SMTP_HOST = cfg.smtp.host; SMTP_SECURITY = cfg.smtp.security; SMTP_USERNAME = cfg.smtp.username; SMTP_PORT = cfg.smtp.port; SMTP_AUTH_MECHANISM = cfg.smtp.auth_mechanism; }; environmentFile = "${dataFolder}/vaultwarden.env"; }; # We create a blank environment file for the service to start. Then, ExecPreStart kicks in and # fills out the environment file for ExecStart to pick it up. systemd.tmpfiles.rules = [ "d ${dataFolder} 0750 vaultwarden vaultwarden" "f ${dataFolder}/vaultwarden.env 0640 vaultwarden vaultwarden" ]; # Needed to be able to write template config. systemd.services.vaultwarden.serviceConfig.ProtectHome = lib.mkForce false; systemd.services.vaultwarden.preStart = shb.replaceSecrets { userConfig = { DATABASE_URL.source = cfg.databasePassword.result.path; DATABASE_URL.transform = v: "postgresql://vaultwarden:${v}@127.0.0.1:5432/vaultwarden"; } // lib.optionalAttrs (cfg.smtp != null) { SMTP_PASSWORD.source = cfg.smtp.password.result.path; }; resultPath = "${dataFolder}/vaultwarden.env"; generator = shb.toEnvVar; }; shb.nginx.vhosts = [ { inherit (cfg) subdomain domain authEndpoint ssl ; upstream = "http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}"; autheliaRules = lib.mkIf (cfg.authEndpoint != null) [ { domain = "${fqdn}"; policy = "two_factor"; subject = [ "group:vaultwarden_admin" ]; resources = [ "^/admin" ]; } # There's no way to protect the webapp using Authelia this way, see # https://github.com/dani-garcia/vaultwarden/discussions/3188 { domain = fqdn; policy = "bypass"; } ]; } ]; shb.postgresql.enableTCPIP = true; shb.postgresql.ensures = [ { username = "vaultwarden"; database = "vaultwarden"; passwordFile = cfg.databasePassword.result.path; } ]; # TODO: make this work. # It does not work because it leads to infinite recursion. # ${cfg.mount}.path = dataFolder; }; } ================================================ FILE: patches/0001-nixos-borgbackup-add-option-to-override-state-direct.patch ================================================ From dda895b551c7cf56476ac8892904e289a4d8b920 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Sat, 1 Nov 2025 13:49:20 +0100 Subject: [PATCH] nixos/borgbackup: add option to override state directory --- nixos/modules/services/backup/borgbackup.nix | 23 +++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/nixos/modules/services/backup/borgbackup.nix b/nixos/modules/services/backup/borgbackup.nix index adabb2ce0f8b..82baeb928398 100644 --- a/nixos/modules/services/backup/borgbackup.nix +++ b/nixos/modules/services/backup/borgbackup.nix @@ -136,7 +136,7 @@ let mkBackupService = name: cfg: let - userHome = config.users.users.${cfg.user}.home; + userHome = if cfg.stateDir != null then cfg.stateDir else config.users.users.${cfg.user}.home; backupJobName = "borgbackup-job-${name}"; backupScript = mkBackupScript backupJobName cfg; in @@ -177,6 +177,7 @@ let environment = { BORG_REPO = cfg.repo; } + // (lib.optionalAttrs (cfg.stateDir != null) { BORG_BASE_DIR = cfg.stateDir; }) // (mkPassEnv cfg) // cfg.environment; }; @@ -223,6 +224,7 @@ let set = { BORG_REPO = cfg.repo; } + // (lib.optionalAttrs (cfg.stateDir != null) { BORG_BASE_DIR = cfg.stateDir; }) // (mkPassEnv cfg) // cfg.environment; }); @@ -232,14 +234,15 @@ let name: cfg: let settings = { inherit (cfg) user group; }; + userHome = if cfg.stateDir != null then cfg.stateDir else config.users.users.${cfg.user}.home; in lib.nameValuePair "borgbackup-job-${name}" ( { # Create parent dirs separately, to ensure correct ownership. - "${config.users.users."${cfg.user}".home}/.config".d = settings; - "${config.users.users."${cfg.user}".home}/.cache".d = settings; - "${config.users.users."${cfg.user}".home}/.config/borg".d = settings; - "${config.users.users."${cfg.user}".home}/.cache/borg".d = settings; + "${userHome}/.config".d = settings; + "${userHome}/.cache".d = settings; + "${userHome}/.config/borg".d = settings; + "${userHome}/.cache/borg".d = settings; } // lib.optionalAttrs (isLocalPath cfg.repo && !cfg.removableDevice) { "${cfg.repo}".d = settings; @@ -487,6 +490,16 @@ in default = "root"; }; + stateDir = lib.mkOption { + type = lib.types.nullOr lib.types.str; + description = '' + Override the directory in which {command}`borg` stores its + configuration and cache. By default it uses the user's + home directory but is some cases this can cause conflicts. + ''; + default = null; + }; + wrapper = lib.mkOption { type = with lib.types; nullOr str; description = '' -- 2.50.1 ================================================ FILE: patches/0001-selfhostblocks-never-onboard.patch ================================================ From 6897dd86a41b336c7c03a466990f7e981c5c649c Mon Sep 17 00:00:00 2001 From: ibizaman Date: Tue, 23 Sep 2025 11:36:24 +0200 Subject: [PATCH] selfhostblocks: never onboard --- backend/open_webui/main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 5630a5883..5c7c88a64 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -1654,11 +1654,9 @@ async def get_app_config(request: Request): user = Users.get_user_by_id(data["id"]) user_count = Users.get_num_users() + # Never onboard onboarding = False - if user is None: - onboarding = user_count == 0 - return { **({"onboarding": True} if onboarding else {}), "status": True, -- 2.50.1 ================================================ FILE: patches/lldap.patch ================================================ From e5a1bf4cb019933621eb059cc6cdd1f8af8df71d Mon Sep 17 00:00:00 2001 From: ibizaman Date: Wed, 13 Aug 2025 08:14:38 +0200 Subject: [PATCH 1/2] lldap-bootstrap: init 0.6.2 --- pkgs/by-name/ll/lldap-bootstrap/package.nix | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 pkgs/by-name/ll/lldap-bootstrap/package.nix diff --git a/pkgs/by-name/ll/lldap-bootstrap/package.nix b/pkgs/by-name/ll/lldap-bootstrap/package.nix new file mode 100644 index 00000000000000..8b9a915b18f4d9 --- /dev/null +++ b/pkgs/by-name/ll/lldap-bootstrap/package.nix @@ -0,0 +1,57 @@ +{ + curl, + fetchFromGitHub, + jq, + jo, + lib, + lldap, + lldap-bootstrap, + makeWrapper, + stdenv, +}: +let + version = "0.6.2"; +in +stdenv.mkDerivation { + pname = "lldap-bootstrap"; + inherit version; + + src = fetchFromGitHub { + owner = "lldap"; + repo = "lldap"; + rev = "v${version}"; + hash = "sha256-UBQWOrHika8X24tYdFfY8ETPh9zvI7/HV5j4aK8Uq+Y="; + }; + + dontBuild = true; + + nativeBuildInputs = [ makeWrapper ]; + + installPhase = '' + mkdir -p $out/bin + cp ./scripts/bootstrap.sh $out/bin/lldap-bootstrap + + wrapProgram $out/bin/lldap-bootstrap \ + --set LLDAP_SET_PASSWORD_PATH ${lldap}/bin/lldap_set_password \ + --prefix PATH : ${ + lib.makeBinPath [ + curl + jq + jo + ] + } + ''; + + meta = { + description = "Bootstrap script for LLDAP"; + homepage = "https://github.com/lldap/lldap"; + changelog = "https://github.com/lldap/lldap/blob/v${lldap-bootstrap.version}/CHANGELOG.md"; + license = lib.licenses.gpl3Only; + platforms = lib.platforms.linux; + maintainers = with lib.maintainers; [ + bendlas + ibizaman + ]; + mainProgram = "lldap-bootstrap"; + }; +} From 6666c710b77e53ea274af4c4dddcb9251b0ccf18 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Wed, 13 Aug 2025 08:15:12 +0200 Subject: [PATCH 2/2] lldap: add ensure options --- nixos/modules/services/databases/lldap.nix | 372 ++++++++++++++++++++- nixos/tests/lldap.nix | 143 +++++++- 2 files changed, 498 insertions(+), 17 deletions(-) diff --git a/nixos/modules/services/databases/lldap.nix b/nixos/modules/services/databases/lldap.nix index fe956c943281..6097f8d06216 100644 --- a/nixos/modules/services/databases/lldap.nix +++ b/nixos/modules/services/databases/lldap.nix @@ -12,6 +12,84 @@ let dbUser = "lldap"; localPostgresql = cfg.database.createLocally && cfg.database.type == "postgresql"; localMysql = cfg.database.createLocally && cfg.database.type == "mariadb"; + + inherit (lib) mkOption types; + + ensureFormat = pkgs.formats.json { }; + ensureGenerate = + let + filterNulls = lib.filterAttrsRecursive (n: v: v != null); + + filteredSource = + source: if builtins.isList source then map filterNulls source else filterNulls source; + in + name: source: ensureFormat.generate name (filteredSource source); + + ensureFieldsOptions = name: { + name = mkOption { + type = types.str; + description = "Name of the field."; + default = name; + }; + + attributeType = mkOption { + type = types.enum [ + "STRING" + "INTEGER" + "JPEG" + "DATE_TIME" + ]; + description = "Attribute type."; + }; + + isEditable = mkOption { + type = types.bool; + description = "Is field editable."; + default = true; + }; + + isList = mkOption { + type = types.bool; + description = "Is field a list."; + default = false; + }; + + isVisible = mkOption { + type = types.bool; + description = "Is field visible in UI."; + default = true; + }; + }; + + allUserGroups = lib.flatten (lib.mapAttrsToList (n: u: u.groups) cfg.ensureUsers); + # The three hardcoded groups are always created when the service starts. + allGroups = lib.mapAttrsToList (n: g: g.name) cfg.ensureGroups ++ [ + "lldap_admin" + "lldap_password_manager" + "lldap_strict_readonly" + ]; + userGroupNotInEnsuredGroup = lib.sortOn lib.id ( + lib.unique (lib.subtractLists allGroups allUserGroups) + ); + someUsersBelongToNonEnsuredGroup = (lib.lists.length userGroupNotInEnsuredGroup) > 0; + + generateEnsureConfigDir = + name: source: + let + genOne = + name: sourceOne: + pkgs.writeTextDir "configs/${name}.json" ( + builtins.readFile (ensureGenerate "configs/${name}.json" sourceOne) + ); + in + "${ + pkgs.symlinkJoin { + inherit name; + paths = lib.mapAttrsToList genOne source; + } + }/configs"; + + quoteVariable = x: "\"${x}\""; in { options.services.lldap = with lib; { @@ -19,6 +97,8 @@ in package = mkPackageOption pkgs "lldap" { }; + bootstrap-package = mkPackageOption pkgs "lldap-bootstrap" { }; + environment = mkOption { type = with types; attrsOf str; default = { }; @@ -203,6 +283,198 @@ in If that is okay for you and you want to silence the warning, set this option to `true`. ''; }; + + ensureUsers = mkOption { + description = '' + Create the users defined here on service startup. + + If `enforceEnsure` option is `true`, the groups + users belong to must be present in the `ensureGroups` option. + + Non-default options must be added to the `ensureGroupFields` option. + ''; + default = { }; + type = types.attrsOf ( + types.submodule ( + { name, ... }: + { + freeformType = ensureFormat.type; + + options = { + id = mkOption { + type = types.str; + description = "Username."; + default = name; + }; + + email = mkOption { + type = types.str; + description = "Email."; + }; + + password_file = mkOption { + type = types.str; + description = "File containing the password."; + }; + + displayName = mkOption { + type = types.nullOr types.str; + default = null; + description = "Display name."; + }; + + firstName = mkOption { + type = types.nullOr types.str; + default = null; + description = "First name."; + }; + + lastName = mkOption { + type = types.nullOr types.str; + default = null; + description = "Last name."; + }; + + avatar_file = mkOption { + type = types.nullOr types.str; + default = null; + description = "Avatar file. Must be a valid path to jpeg file (ignored if avatar_url specified)"; + }; + + avatar_url = mkOption { + type = types.nullOr types.str; + default = null; + description = "Avatar url. must be a valid URL to jpeg file (ignored if gravatar_avatar specified)"; + }; + + gravatar_avatar = mkOption { + type = types.nullOr types.str; + default = null; + description = "Get avatar from Gravatar using the email."; + }; + + weser_avatar = mkOption { + type = types.nullOr types.str; + default = null; + description = "Convert avatar retrieved by gravatar or the URL."; + }; + + groups = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Groups the user would be a member of (all the groups must be specified in group config files)."; + }; + }; + } + ) + ); + }; + + ensureGroups = mkOption { + description = '' + Create the groups defined here on service startup. + + Non-default options must be added to the `ensureGroupFields` option. + ''; + default = { }; + type = types.attrsOf ( + types.submodule ( + { name, ... }: + { + freeformType = ensureFormat.type; + + options = { + name = mkOption { + type = types.str; + description = "Name of the group."; + default = name; + }; + }; + } + ) + ); + }; + + ensureUserFields = mkOption { + description = "Extra fields for users"; + default = { }; + type = types.attrsOf ( + types.submodule ( + { name, ... }: + { + options = ensureFieldsOptions name; + } + ) + ); + }; + + ensureGroupFields = mkOption { + description = "Extra fields for groups"; + default = { }; + type = types.attrsOf ( + types.submodule ( + { name, ... }: + { + options = ensureFieldsOptions name; + } + ) + ); + }; + + ensureAdminUsername = mkOption { + type = types.str; + default = "admin"; + description = '' + Username of the default admin user with which to connect to the LLDAP service. + + By default, it is `"admin"`. + Extra admin users can be added using the `services.lldap.ensureUsers` option and adding them to the correct groups. + ''; + }; + + ensureAdminPassword = mkOption { + type = types.nullOr types.str; + defaultText = "config.services.lldap.settings.ldap_user_pass"; + default = cfg.settings.ldap_user_pass or null; + description = '' + Password of an admin user with which to connect to the LLDAP service. + + By default, it is the same as the password for the default admin user 'admin'. + If using a password from another user, it must be managed manually. + + Unsecure. Use `services.lldap.ensureAdminPasswordFile` option instead. + ''; + }; + + ensureAdminPasswordFile = mkOption { + type = types.nullOr types.str; + defaultText = "config.services.lldap.settings.ldap_user_pass_file"; + default = cfg.settings.ldap_user_pass_file or null; + description = '' + Path to the file containing the password of an admin user with which to connect to the LLDAP service. + + By default, it is the same as the password for the default admin user 'admin'. + If using a password from another user, it must be managed manually. + ''; + }; + + enforceUsers = mkOption { + description = "Delete users not managed declaratively."; + type = types.bool; + default = false; + }; + + enforceUserMemberships = mkOption { + description = "Remove users from groups they do not belong to declaratively."; + type = types.bool; + default = false; + }; + + enforceGroups = mkOption { + description = "Delete groups not managed declaratively."; + type = types.bool; + default = false; + }; }; config = lib.mkIf cfg.enable { @@ -219,25 +491,77 @@ in (cfg.settings.ldap_user_pass_file or null) == null || (cfg.settings.ldap_user_pass or null) == null; message = "lldap: Both `ldap_user_pass` and `ldap_user_pass_file` settings should not be set at the same time. Set one to `null`."; } + { + assertion = + cfg.ensureUsers != { } + || cfg.ensureGroups != { } + || cfg.ensureUserFields != { } + || cfg.ensureGroupFields != { } + || cfg.enforceUsers + || cfg.enforceUserMemberships + || cfg.enforceGroups + -> cfg.ensureAdminPassword != null || cfg.ensureAdminPasswordFile != null; + message = '' + lldap: Some ensure options are set but no admin user password is set. + Add a default password to the `ldap_user_pass` or `ldap_user_pass_file` setting and set `force_ldap_user_pass_reset` to `true` to manage the admin user declaratively + or create an admin user manually and set its password in `ensureAdminPasswordFile` option. + ''; + } + { + assertion = cfg.enforceUserMemberships -> !someUsersBelongToNonEnsuredGroup; + message = '' + lldap: Some users belong to groups not present in the ensureGroups attr, + add the following groups or remove them from the groups a user belong to: + ${lib.concatMapStringsSep quoteVariable ", " userGroupNotInEnsuredGroup} + ''; + } + ( + let + getNames = source: lib.flatten (lib.mapAttrsToList (x: v: v.name) source); + allNames = getNames cfg.ensureUserFields ++ getNames cfg.ensureGroupFields; + validFieldName = name: lib.match "[a-zA-Z0-9-]+" name != null; + in + { + assertion = lib.all validFieldName allNames; + message = '' + lldap: The following custom user or group fields have invalid names. Valid characters are: a-z, A-Z, 0-9, and dash (-). + The offending fields are: ${ + lib.concatMapStringsSep quoteVariable ", " (lib.filter (x: !(validFieldName x)) allNames) + } + ''; + } + ) ]; warnings = - lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [ + (lib.optionals (cfg.ensureAdminPassword != null) [ + '' + lldap: Unsecure option `ensureAdminPassword` is used. Prefer `ensureAdminPasswordFile` instead. + '' + ]) + ++ (lib.optionals ((cfg.settings.ldap_user_pass or null) != null) [ '' lldap: Unsecure `ldap_user_pass` setting is used. Prefer `ldap_user_pass_file` instead. '' - ] - ++ - lib.optionals - (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false) - [ - '' - lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means - the admin password can be changed through the UI and will drift from the one defined in your nix config. - It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password. - Either set `force_ldap_user_pass_reset` to `"always"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`. - '' - ]; + ]) + ++ (lib.optionals + (cfg.settings.force_ldap_user_pass_reset == false && cfg.silenceForceUserPassResetWarning == false) + [ + '' + lldap: The `force_ldap_user_pass_reset` setting is set to `false` which means + the admin password can be changed through the UI and will drift from the one defined in your nix config. + It also means changing the setting `ldap_user_pass` or `ldap_user_pass_file` will have no effect on the admin password. + Either set `force_ldap_user_pass_reset` to `"always"` or silence this warning by setting the option `services.lldap.silenceForceUserPassResetWarning` to `true`. + '' + ] + ) + ++ (lib.optionals (!cfg.enforceUserMemberships && someUsersBelongToNonEnsuredGroup) [ + '' + Some users belong to groups not managed by the configuration here, + make sure the following groups exist or the service will not start properly: + ${lib.concatStringsSep ", " (map (x: "\"${x}\"") userGroupNotInEnsuredGroup)} + '' + ]); services.lldap.settings.database_url = lib.mkIf cfg.database.createLocally ( lib.mkDefault ( @@ -279,6 +603,28 @@ in + '' exec ${lib.getExe cfg.package} run --config-file ${format.generate "lldap_config.toml" cfg.settings} ''; + postStart = '' + export LLDAP_URL=http://127.0.0.1:${toString cfg.settings.http_port} + export LLDAP_ADMIN_USERNAME=${cfg.ensureAdminUsername} + export LLDAP_ADMIN_PASSWORD=${ + if cfg.ensureAdminPassword != null then cfg.ensureAdminPassword else "" + } + export LLDAP_ADMIN_PASSWORD_FILE=${ + if cfg.ensureAdminPasswordFile != null then cfg.ensureAdminPasswordFile else "" + } + export USER_CONFIGS_DIR=${generateEnsureConfigDir "users" cfg.ensureUsers} + export GROUP_CONFIGS_DIR=${generateEnsureConfigDir "groups" cfg.ensureGroups} + export USER_SCHEMAS_DIR=${ + generateEnsureConfigDir "userFields" (lib.mapAttrs (n: v: [ v ]) cfg.ensureUserFields) + } + export GROUP_SCHEMAS_DIR=${ + generateEnsureConfigDir "groupFields" (lib.mapAttrs (n: v: [ v ]) cfg.ensureGroupFields) + } + export DO_CLEANUP_USERS=${if cfg.enforceUsers then "true" else "false"} + export DO_CLEANUP_USER_MEMBERSHIPS=${if cfg.enforceUserMemberships then "true" else "false"} + export DO_CLEANUP_GROUPS=${if cfg.enforceGroups then "true" else "false"} + ${lib.getExe cfg.bootstrap-package} + ''; serviceConfig = { StateDirectory = "lldap"; StateDirectoryMode = "0750"; diff --git a/nixos/tests/lldap.nix b/nixos/tests/lldap.nix index 8e38d4bdefa3..47d32c7a2a7b 100644 --- a/nixos/tests/lldap.nix +++ b/nixos/tests/lldap.nix @@ -1,6 +1,9 @@ { ... }: let adminPassword = "mySecretPassword"; + alicePassword = "AlicePassword"; + bobPassword = "BobPassword"; + charliePassword = "CharliePassword"; in { name = "lldap"; @@ -26,7 +29,7 @@ in { services.lldap.settings = { ldap_user_pass = lib.mkForce null; - ldap_user_pass_file = lib.mkForce (toString (pkgs.writeText "adminPasswordFile" adminPassword)); + ldap_user_pass_file = toString (pkgs.writeText "adminPasswordFile" adminPassword); force_ldap_user_pass_reset = "always"; }; }; @@ -40,13 +43,110 @@ in force_ldap_user_pass_reset = false; }; }; + + withAlice.configuration = + { ... }: + { + services.lldap = { + enforceUsers = true; + enforceUserMemberships = true; + enforceGroups = true; + + # This password was set in the "differentAdminPassword" specialisation. + ensureAdminPasswordFile = toString (pkgs.writeText "adminPasswordFile" adminPassword); + + ensureUsers = { + alice = { + email = "alice@example.com"; + password_file = toString (pkgs.writeText "alicePasswordFile" alicePassword); + groups = [ "mygroup" ]; + }; + }; + + ensureGroups = { + mygroup = { }; + }; + }; + }; + + withBob.configuration = + { ... }: + { + services.lldap = { + enforceUsers = true; + enforceUserMemberships = true; + enforceGroups = true; + + # This time we check that ensureAdminPasswordFile correctly defaults to `settings.ldap_user_pass_file` + settings = { + ldap_user_pass = lib.mkForce "password"; + force_ldap_user_pass_reset = "always"; + }; + + ensureUsers = { + bob = { + email = "bob@example.com"; + password_file = toString (pkgs.writeText "bobPasswordFile" bobPassword); + groups = [ "bobgroup" ]; + displayName = "Bob"; + }; + }; + + ensureGroups = { + bobgroup = { }; + }; + }; + }; + + withAttributes.configuration = + { ... }: + { + services.lldap = { + enforceUsers = true; + enforceUserMemberships = true; + enforceGroups = true; + + settings = { + ldap_user_pass = lib.mkForce adminPassword; + force_ldap_user_pass_reset = "always"; + }; + + ensureUsers = { + charlie = { + email = "charlie@example.com"; + password_file = toString (pkgs.writeText "charliePasswordFile" charliePassword); + groups = [ "othergroup" ]; + displayName = "Charlie"; + myattribute = 2; + }; + }; + + ensureGroups = { + othergroup = { + mygroupattribute = "Managed by NixOS"; + }; + }; + + ensureUserFields = { + myattribute = { + attributeType = "INTEGER"; + }; + }; + + ensureGroupFields = { + mygroupattribute = { + attributeType = "STRING"; + }; + }; + }; + }; }; }; testScript = { nodes, ... }: let - specializations = "${nodes.machine.system.build.toplevel}/specialisation"; + specialisations = "${nodes.machine.system.build.toplevel}/specialisation"; in '' machine.wait_for_unit("lldap.service") @@ -56,6 +156,9 @@ in machine.succeed("curl --location --fail http://localhost:17170/") adminPassword="${adminPassword}" + alicePassword="${alicePassword}" + bobPassword="${bobPassword}" + charliePassword="${charliePassword}" def try_login(user, password, expect_success=True): cmd = f'ldapsearch -H ldap://localhost:3890 -D uid={user},ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w {password}' @@ -70,18 +173,50 @@ in raise Exception("Expected failure, had success") return response + def parse_ldapsearch_output(output): + return {n:v for (n, v) in (x.split(': ', 2) for x in output.splitlines() if x != "")} + with subtest("default admin password"): try_login("admin", "password", expect_success=True) try_login("admin", adminPassword, expect_success=False) with subtest("different admin password"): - machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test') + machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test') try_login("admin", "password", expect_success=False) try_login("admin", adminPassword, expect_success=True) with subtest("change admin password has no effect"): - machine.succeed('${specializations}/differentAdminPassword/bin/switch-to-configuration test') + machine.succeed('${specialisations}/differentAdminPassword/bin/switch-to-configuration test') try_login("admin", "password", expect_success=False) try_login("admin", adminPassword, expect_success=True) + + with subtest("with alice"): + machine.succeed('${specialisations}/withAlice/bin/switch-to-configuration test') + try_login("alice", "password", expect_success=False) + try_login("alice", alicePassword, expect_success=True) + try_login("bob", "password", expect_success=False) + try_login("bob", bobPassword, expect_success=False) + + with subtest("with bob"): + machine.succeed('${specialisations}/withBob/bin/switch-to-configuration test') + try_login("alice", "password", expect_success=False) + try_login("alice", alicePassword, expect_success=False) + try_login("bob", "password", expect_success=False) + try_login("bob", bobPassword, expect_success=True) + + with subtest("with attributes"): + machine.succeed('${specialisations}/withAttributes/bin/switch-to-configuration test') + + response = machine.succeed(f'ldapsearch -LLL -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "dc=example,dc=com" -w {adminPassword} "(uid=charlie)"') + print(response) + charlie = parse_ldapsearch_output(response) + if charlie.get('myattribute') != "2": + raise Exception(f'Unexpected value for attribute "myattribute": {charlie.get('myattribute')}') + + response = machine.succeed(f'ldapsearch -LLL -H ldap://localhost:3890 -D uid=admin,ou=people,dc=example,dc=com -b "dc=example,dc=com" -w {adminPassword} "(cn=othergroup)"') + print(response) + othergroup = parse_ldapsearch_output(response) + if othergroup.get('mygroupattribute') != "Managed by NixOS": + raise Exception(f'Unexpected value for attribute "mygroupattribute": {othergroup.get('mygroupattribute')}') ''; } -- ================================================ FILE: patches/nextcloudexternalstorage.patch ================================================ diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php index 260f9218a88..26e5a4172f7 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -66,9 +66,12 @@ class Local extends \OC\Files\Storage\Common { $this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false); if (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) { - // data dir not accessible or available, can happen when using an external storage of type Local - // on an unmounted system mount point - throw new StorageNotAvailableException('Local storage path does not exist "' . $this->getSourcePath('') . '"'); + if (!$this->mkdir('')) { + // data dir not accessible or available, can happen when using an external storage of type Local + // on an unmounted system mount point + throw new StorageNotAvailableException('Local storage path does not exist and could not create it "' . $this->getSourcePath('') . '"'); + } + Server::get(LoggerInterface::class)->warning('created local storage path ' . $this->getSourcePath(''), ['app' => 'core']); } } -- 2.50.1 ================================================ FILE: test/blocks/authelia.nix ================================================ { pkgs, shb, ... }: let pkgs' = pkgs; ldapAdminPassword = "ldapAdminPassword"; in { basic = shb.test.runNixOSTest { name = "authelia-basic"; nodes.machine = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") ../../modules/blocks/authelia.nix ../../modules/blocks/hardcodedsecret.nix ]; networking.hosts = { "127.0.0.1" = [ "machine.com" "client1.machine.com" "client2.machine.com" "ldap.machine.com" "authelia.machine.com" ]; }; shb.lldap = { enable = true; dcdomain = "dc=example,dc=com"; subdomain = "ldap"; domain = "machine.com"; ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; }; shb.hardcodedsecret.ldapUserPassword = { request = config.shb.lldap.ldapUserPassword.request; settings.content = ldapAdminPassword; }; shb.hardcodedsecret.jwtSecret = { request = config.shb.lldap.jwtSecret.request; settings.content = "jwtsecret"; }; shb.authelia = { enable = true; subdomain = "authelia"; domain = "machine.com"; ldapHostname = "${config.shb.lldap.subdomain}.${config.shb.lldap.domain}"; ldapPort = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; secrets = { jwtSecret.result = config.shb.hardcodedsecret.autheliaJwtSecret.result; ldapAdminPassword.result = config.shb.hardcodedsecret.ldapAdminPassword.result; sessionSecret.result = config.shb.hardcodedsecret.sessionSecret.result; storageEncryptionKey.result = config.shb.hardcodedsecret.storageEncryptionKey.result; identityProvidersOIDCHMACSecret.result = config.shb.hardcodedsecret.identityProvidersOIDCHMACSecret.result; identityProvidersOIDCIssuerPrivateKey.result = config.shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey.result; }; oidcClients = [ { client_id = "client1"; client_name = "My Client 1"; client_secret.source = pkgs.writeText "secret" "$pbkdf2-sha512$310000$LR2wY11djfLrVQixdlLJew$rPByqFt6JfbIIAITxzAXckwh51QgV8E5YZmA8rXOzkMfBUcMq7cnOKEXF6MAFbjZaGf3J/B1OzLWZTCuZtALVw"; public = false; authorization_policy = "one_factor"; redirect_uris = [ "http://client1.machine.com/redirect" ]; } { client_id = "client2"; client_name = "My Client 2"; client_secret.source = pkgs.writeText "secret" "$pbkdf2-sha512$310000$76EqVU1N9K.iTOvD4WJ6ww$hqNJU.UHphiCjMChSqk27lUTjDqreuMuyV/u39Esc6HyiRXp5Ecx89ypJ5M0xk3Na97vbgDpwz7il5uwzQ4bfw"; public = false; authorization_policy = "one_factor"; redirect_uris = [ "http://client2.machine.com/redirect" ]; } ]; }; shb.hardcodedsecret.autheliaJwtSecret = { request = config.shb.authelia.secrets.jwtSecret.request; settings.content = "jwtSecret"; }; shb.hardcodedsecret.ldapAdminPassword = { request = config.shb.authelia.secrets.ldapAdminPassword.request; settings.content = ldapAdminPassword; }; shb.hardcodedsecret.sessionSecret = { request = config.shb.authelia.secrets.sessionSecret.request; settings.content = "sessionSecret"; }; shb.hardcodedsecret.storageEncryptionKey = { request = config.shb.authelia.secrets.storageEncryptionKey.request; settings.content = "storageEncryptionKey"; }; shb.hardcodedsecret.identityProvidersOIDCHMACSecret = { request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request; settings.content = "identityProvidersOIDCHMACSecret"; }; shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey = { request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request; settings.source = (pkgs.runCommand "gen-private-key" { } '' mkdir $out ${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096 '') + "/private.pem"; }; specialisation = { withDebug.configuration = { shb.authelia.debug = true; }; }; }; testScript = { nodes, ... }: let specializations = "${nodes.machine.system.build.toplevel}/specialisation"; in '' import json start_all() def tests(): machine.wait_for_unit("lldap.service") machine.wait_for_unit("authelia-authelia.machine.com.target") machine.wait_for_open_port(9091) endpoints = json.loads(machine.succeed("curl -s http://machine.com/.well-known/openid-configuration")) auth_endpoint = endpoints['authorization_endpoint'] print(f"auth_endpoint: {auth_endpoint}") if auth_endpoint != "http://machine.com/api/oidc/authorization": raise Exception("Unexpected auth_endpoint") resp = machine.succeed( "curl -f -s '" + auth_endpoint + "?client_id=other" + "&redirect_uri=http://client1.machine.com/redirect" + "&scope=openid%20profile%20email" + "&response_type=code" + "&state=99999999'" ) print(resp) if resp != "": raise Exception("unexpected response") resp = machine.succeed( "curl -f -s '" + auth_endpoint + "?client_id=client1" + "&redirect_uri=http://client1.machine.com/redirect" + "&scope=openid%20profile%20email" + "&response_type=code" + "&state=11111111'" ) print(resp) if "Found" not in resp: raise Exception("unexpected response") resp = machine.succeed( "curl -f -s '" + auth_endpoint + "?client_id=client2" + "&redirect_uri=http://client2.machine.com/redirect" + "&scope=openid%20profile%20email" + "&response_type=code" + "&state=22222222'" ) print(resp) if "Found" not in resp: raise Exception("unexpected response") with subtest("no debug"): tests() with subtest("with debug"): machine.succeed('${specializations}/withDebug/bin/switch-to-configuration test') tests() ''; }; } ================================================ FILE: test/blocks/borgbackup.nix ================================================ { shb, ... }: let commonTest = user: shb.test.runNixOSTest { name = "borgbackup_backupAndRestore_${user}"; nodes.machine = { config, ... }: { imports = [ shb.test.baseImports ../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/borgbackup.nix ]; shb.hardcodedsecret.A = { request = { owner = "root"; group = "keys"; mode = "0440"; }; settings.content = "secretA"; }; shb.hardcodedsecret.B = { request = { owner = "root"; group = "keys"; mode = "0440"; }; settings.content = "secretB"; }; shb.hardcodedsecret.passphrase = { request = config.shb.borgbackup.instances."testinstance".settings.passphrase.request; settings.content = "passphrase"; }; shb.borgbackup.instances."testinstance" = { settings = { enable = true; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = "/opt/repos/A"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "5h"; }; # Those are not needed by the repository but are still included # so we can test them in the hooks section. secrets = { A.source = config.shb.hardcodedsecret.A.result.path; B.source = config.shb.hardcodedsecret.B.result.path; }; }; }; request = { inherit user; sourceDirectories = [ "/opt/files/A" "/opt/files/B" ]; hooks.beforeBackup = [ '' echo $RUNTIME_DIRECTORY if [ "$RUNTIME_DIRECTORY" = /run/borgbackup-backups-testinstance_opt_repos_A ]; then if ! [ -f /run/secrets_borgbackup/borgbackup-backups-testinstance_opt_repos_A ]; then exit 10 fi if [ -z "$A" ] || ! [ "$A" = "secretA" ]; then echo "A:$A" exit 11 fi if [ -z "$B" ] || ! [ "$B" = "secretB" ]; then echo "B:$B" exit 12 fi fi '' ]; }; }; }; extraPythonPackages = p: [ p.dictdiffer ]; skipTypeCheck = true; testScript = { nodes, ... }: let provider = nodes.machine.shb.borgbackup.instances."testinstance"; backupService = provider.result.backupService; restoreScript = provider.result.restoreScript; in '' from dictdiffer import diff def list_files(dir): files_and_content = {} files = machine.succeed(f""" find {dir} -type f """).split("\n")[:-1] for f in files: content = machine.succeed(f""" cat {f} """).strip() files_and_content[f] = content return files_and_content def assert_files(dir, files): result = list(diff(list_files(dir), files)) if len(result) > 0: raise Exception("Unexpected files:", result) with subtest("Create initial content"): machine.succeed(""" mkdir -p /opt/files/A mkdir -p /opt/files/B echo repoA_fileA_1 > /opt/files/A/fileA echo repoA_fileB_1 > /opt/files/A/fileB echo repoB_fileA_1 > /opt/files/B/fileA echo repoB_fileB_1 > /opt/files/B/fileB chown ${user}: -R /opt/files chmod go-rwx -R /opt/files """) assert_files("/opt/files", { '/opt/files/B/fileA': 'repoB_fileA_1', '/opt/files/B/fileB': 'repoB_fileB_1', '/opt/files/A/fileA': 'repoA_fileA_1', '/opt/files/A/fileB': 'repoA_fileB_1', }) with subtest("First backup in repo A"): machine.succeed("systemctl start ${backupService}") with subtest("New content"): machine.succeed(""" echo repoA_fileA_2 > /opt/files/A/fileA echo repoA_fileB_2 > /opt/files/A/fileB echo repoB_fileA_2 > /opt/files/B/fileA echo repoB_fileB_2 > /opt/files/B/fileB """) assert_files("/opt/files", { '/opt/files/B/fileA': 'repoB_fileA_2', '/opt/files/B/fileB': 'repoB_fileB_2', '/opt/files/A/fileA': 'repoA_fileA_2', '/opt/files/A/fileB': 'repoA_fileB_2', }) with subtest("Delete content"): machine.succeed(""" rm -r /opt/files/A /opt/files/B """) assert_files("/opt/files", {}) with subtest("Restore initial content from repo A"): machine.succeed(""" ${restoreScript} restore latest """) assert_files("/opt/files", { '/opt/files/B/fileA': 'repoB_fileA_1', '/opt/files/B/fileB': 'repoB_fileB_1', '/opt/files/A/fileA': 'repoA_fileA_1', '/opt/files/A/fileB': 'repoA_fileB_1', }) ''; }; in { backupAndRestoreRoot = commonTest "root"; backupAndRestoreUser = commonTest "nobody"; } ================================================ FILE: test/blocks/keypair.pem ================================================ -----BEGIN PRIVATE KEY----- MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC2x1rFx98p6djQ X0mJ8nUMnS3LU7ih8UU5soW/TVhdcioe+NUevLjq0Qe/RXAz+yjhAxmJoWsFwMuy PKQKDbnqLH6Rf2qPiOvg5NB/vINuVpnAo9mQUJlJOrWKe6/pk6FWD0YxGiwETEIX bdARazOo/n5emQCZ7XwFy9ULlZkURIt9co/SxhMaD0Q+P1eev0lt01XG5ZLg9wL8 CHIkasqK5huKFUnHVyq8+ApVrzsANsvjFSLwd985FpIF+DYcVQ+8ZmwVrbZTzFFC Pjee8CrhO1ZxOAPwVm7qNTtZ4es8xJyMJvijk6grqLGcWSIWTMAwxmN7feOuEvvk RlDf9DmpAgMBAAECggEACP51jySrDLAQ/wznTJpRi+u6loYhwFdD26dXGT8kNWHy JGjEbPEmrMhZKB5xu18VJ4Bca/c1UdjHNTeybzu2balfl4eEbfhz+fKsd1KmiYIJ qg7t/GHY7x9sUjqMoRLmhhp1juI9rv71JBu/WLIUnlDalUtUWh6zYwIhE0M634I8 GjN4hCxvbVgQEyY4kMBvCcT9sixwm407qL7LfqlsT8KTGB9UU2cC1HD4B/pUKVzw x+vN93S6KS2SrjaYhAb1xHgxU6Bl1jT1IH8yAXVlmBBmDL9dNEJtD/kuX8kfbvuC yFY5NWVapSgyIhURkaHqJKmziaq51K1xHGCZYBDsYQKBgQDq0ovgTNBzgmwyl/ye ZgUIWc/5tE2LlyoM8XTok9EB+8CnoBek8JFo9DVfNzTv+UCUXb7DCveuS1Jb0JY0 Xi4gOSczVV297Lszziogxuni4ax/1Nezah/WSffVEowakPuTLK+0dst0QxWC6+Db m4OHJY5qjS/mh3rLhFdjXcmAoQKBgQDHQ0BAg8AhlFz9fTxit1pyHuVs1EcEBjqI UOS1ClS+BDcjERVBJ8GKiZj2/la37OLlQuguH2AXX//wVC5rZEXP38+ELemW0BZC JFKaY5PYufMcGVd6JBDYCoEa/JERJsD87ADBAUj/kIMfvka/it9PID9jgMPaVESE LYIsRv40CQKBgQCtdJ0yMEuCJ4L41GAcOUvaYU1JLDBjvmOnb+xlqFqpVmd26sDM a49dsZaDIOqPoNRdQ+oXdNCEBMtvWuK5CCCWWOFl/9bg5i9aEx33XDeECiM7weMb enbN+ZGB6NNpBFNw4X9glKew16TaMpbEYVmEyO8sMeKCLO09zCIpGiwwQQKBgQCF ++dhOfXf3mXkoOgQrJ8pazLzSY1y3ElRTatrPEYc+rKkZqE3DWdrIvhy5DQlOiia 5bE/CiPPs+JhlAkedu8mRqS/iSuvF75PvSK540kPioE4nKWgYE3fJrkHD1rwAHH1 3y7mmFmgVmiE2Kmzs8pR5yoYWwXWcaEci4kjAp19GQKBgQDRpy4ojGUmKdDffcGU pEpl+dGpC3YuGwEsopDTYJSjANq0p5QGcQo9L140XxBEaFd4k/jwvVh2VRx4KmkC wyFODOk4vbq1NKljLC9yRo6UbUZuzWBsyjP62OHPR5MBg5FQgd4RI6/c3EpAhFGX pM/CH7yZXp7Brhp4RcdbwhQnIA== -----END PRIVATE KEY----- ================================================ FILE: test/blocks/lib.nix ================================================ { pkgs, lib, shb, ... }: let pkgs' = pkgs; in { template = let aSecret = pkgs.writeText "a-secret.txt" "Secret of A"; bSecret = pkgs.writeText "b-secret.txt" "Secret of B"; userConfig = { a.a.source = aSecret; b.source = bSecret; b.transform = v: "prefix-${v}-suffix"; c = "not secret C"; d.d = "not secret D"; }; wantedConfig = { a.a = "Secret of A"; b = "prefix-Secret of B-suffix"; c = "not secret C"; d.d = "not secret D"; }; configWithTemplates = shb.withReplacements userConfig; nonSecretConfigFile = pkgs.writeText "config.yaml.template" ( lib.generators.toJSON { } configWithTemplates ); replacements = shb.getReplacements userConfig; replaceInTemplate = shb.replaceSecretsScript { file = nonSecretConfigFile; resultPath = "/var/lib/config.yaml"; inherit replacements; }; replaceInTemplateJSON = shb.replaceSecrets { inherit userConfig; resultPath = "/var/lib/config.json"; generator = shb.replaceSecretsFormatAdapter (pkgs.formats.json { }); }; replaceInTemplateJSONGen = shb.replaceSecrets { inherit userConfig; resultPath = "/var/lib/config_gen.json"; generator = shb.replaceSecretsGeneratorAdapter (lib.generators.toJSON { }); }; replaceInTemplateXML = shb.replaceSecrets { inherit userConfig; resultPath = "/var/lib/config.xml"; generator = shb.replaceSecretsFormatAdapter (shb.formatXML { enclosingRoot = "Root"; }); }; in shb.test.runNixOSTest { name = "lib-template"; nodes.machine = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") { options = { libtest.config = lib.mkOption { type = lib.types.attrsOf ( lib.types.oneOf [ lib.types.str lib.secretFileType ] ); }; }; } ]; system.activationScripts = { libtest = replaceInTemplate; libtestJSON = replaceInTemplateJSON; libtestJSONGen = replaceInTemplateJSONGen; libtestXML = replaceInTemplateXML; }; }; testScript = { nodes, ... }: '' import json from collections import ChainMap from xml.etree import ElementTree start_all() machine.wait_for_file("/var/lib/config.yaml") machine.wait_for_file("/var/lib/config.json") machine.wait_for_file("/var/lib/config_gen.json") machine.wait_for_file("/var/lib/config.xml") def xml_to_dict_recursive(root): all_descendants = list(root) if len(all_descendants) == 0: return {root.tag: root.text} else: merged_dict = ChainMap(*map(xml_to_dict_recursive, all_descendants)) return {root.tag: dict(merged_dict)} wantedConfig = json.loads('${lib.generators.toJSON { } wantedConfig}') with subtest("config"): print(machine.succeed("cat ${pkgs.writeText "replaceInTemplate" replaceInTemplate}")) gotConfig = machine.succeed("cat /var/lib/config.yaml") print(gotConfig) gotConfig = json.loads(gotConfig) if wantedConfig != gotConfig: raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) with subtest("config JSON Gen"): print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSONGen" replaceInTemplateJSONGen}")) gotConfig = machine.succeed("cat /var/lib/config_gen.json") print(gotConfig) gotConfig = json.loads(gotConfig) if wantedConfig != gotConfig: raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) with subtest("config JSON"): print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateJSON" replaceInTemplateJSON}")) gotConfig = machine.succeed("cat /var/lib/config.json") print(gotConfig) gotConfig = json.loads(gotConfig) if wantedConfig != gotConfig: raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) with subtest("config XML"): print(machine.succeed("cat ${pkgs.writeText "replaceInTemplateXML" replaceInTemplateXML}")) gotConfig = machine.succeed("cat /var/lib/config.xml") print(gotConfig) gotConfig = xml_to_dict_recursive(ElementTree.XML(gotConfig))['Root'] if wantedConfig != gotConfig: raise Exception("\nwantedConfig: {}\n!= gotConfig: {}".format(wantedConfig, gotConfig)) ''; }; } ================================================ FILE: test/blocks/lldap.nix ================================================ { pkgs, lib, shb, ... }: let pkgs' = pkgs; password = "securepassword"; charliePassword = "CharliePassword"; in { auth = shb.test.runNixOSTest { name = "ldap-auth"; nodes.server = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") { options = { shb.ssl.enable = lib.mkEnableOption "ssl"; }; } ../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/lldap.nix ]; shb.lldap = { enable = true; dcdomain = "dc=example,dc=com"; subdomain = "ldap"; domain = "example.com"; ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; ensureUsers = { "charlie" = { email = "charlie@example.com"; password.result = config.shb.hardcodedsecret."charlie".result; }; }; ensureGroups = { "family" = { }; }; }; shb.hardcodedsecret.ldapUserPassword = { request = config.shb.lldap.ldapUserPassword.request; settings.content = password; }; shb.hardcodedsecret.jwtSecret = { request = config.shb.lldap.jwtSecret.request; settings.content = "jwtSecret"; }; shb.hardcodedsecret."charlie" = { request = config.shb.lldap.ensureUsers."charlie".password.request; settings.content = charliePassword; }; networking.firewall.allowedTCPPorts = [ 80 ]; # nginx port environment.systemPackages = [ pkgs.openldap ]; specialisation = { withDebug.configuration = { shb.lldap.debug = true; }; }; }; nodes.client = { }; # Inspired from https://github.com/lldap/lldap/blob/33f50d13a2e2d24a3e6bb05a148246bc98090df0/example_configs/lldap-ha-auth.sh testScript = { nodes, ... }: let specializations = "${nodes.server.system.build.toplevel}/specialisation"; in '' import json start_all() def tests(): server.wait_for_unit("lldap.service") server.wait_for_open_port(${toString nodes.server.shb.lldap.webUIListenPort}) server.wait_for_open_port(${toString nodes.server.shb.lldap.ldapPort}) with subtest("fail without authenticating"): client.fail( "curl -f -s -X GET" + """ -H "Content-type: application/json" """ + """ -H "Host: ldap.example.com" """ + " http://server/api/graphql" ) with subtest("fail authenticating with wrong credentials"): resp = client.fail( "curl -f -s -X POST" + """ -H "Content-type: application/json" """ + """ -H "Host: ldap.example.com" """ + " http://server/auth/simple/login" + """ -d '{"username": "admin", "password": "wrong"}'""" ) print(resp) with subtest("succeed with correct authentication"): token = json.loads(client.succeed( "curl -f -s -X POST " + """ -H "Content-type: application/json" """ + """ -H "Host: ldap.example.com" """ + " http://server/auth/simple/login " + """ -d '{"username": "admin", "password": "${password}"}' """ ))['token'] data = json.loads(client.succeed( "curl -f -s -X POST " + """ -H "Content-type: application/json" """ + """ -H "Host: ldap.example.com" """ + """ -H "Authorization: Bearer {token}" """.format(token=token) + " http://server/api/graphql " + """ -d '{"variables": {"id": "admin"}, "query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' """ ))['data'] assert data['user']['displayName'] == "Administrator" assert data['user']['groups'][0]['displayName'] == "lldap_admin" with subtest("succeed charlie"): resp = client.succeed( "curl -f -s -X POST " + """ -H "Content-type: application/json" """ + """ -H "Host: ldap.example.com" """ + " http://server/auth/simple/login " + """ -d '{"username": "charlie", "password": "${charliePassword}"}' """ ) print(resp) with subtest("ldap user search"): resp = server.succeed('ldapsearch -H ldap://127.0.0.1:${toString nodes.server.shb.lldap.ldapPort} -D uid=admin,ou=people,dc=example,dc=com -b "ou=people,dc=example,dc=com" -w ${password}') print(resp) if "uid=admin" not in resp: raise Exception("Expected to find admin") if "uid=charlie" not in resp: raise Exception("Expected to find charlie") with subtest("no debug"): tests() with subtest("with debug"): server.succeed('${specializations}/withDebug/bin/switch-to-configuration test') tests() ''; }; } ================================================ FILE: test/blocks/mitmdump.nix ================================================ { pkgs, lib, shb, ... }: let serve = port: text: lib.getExe ( pkgs.writers.writePython3Bin "serve" { libraries = [ pkgs.python3Packages.systemd-python ]; } ( let content = pkgs.writeText "content" text; in '' from http.server import BaseHTTPRequestHandler, HTTPServer from systemd.daemon import notify with open("${content}", "rb") as f: content = f.read() class HardcodedHandler(BaseHTTPRequestHandler): def do_GET(self): reponse = content + self.path.encode('utf-8') self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(reponse))) self.end_headers() print("answering to GET request") self.wfile.write(reponse) def log_message(self, format, *args): pass # optional: suppress logging if __name__ == "__main__": notify('STATUS=Starting up...') server_address = ('127.0.0.1', ${toString port}) httpd = HTTPServer(server_address, HardcodedHandler) print("Serving hardcoded page on http://127.0.0.1:${toString port}") notify('READY=1') httpd.serve_forever() '' ) ); in { default = shb.test.runNixOSTest { name = "mitmdump-default"; nodes.machine = { config, pkgs, ... }: { imports = [ ../../modules/blocks/mitmdump.nix ]; systemd.services.test1 = { serviceConfig.ExecStart = serve 8000 "test1"; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "notify"; StandardOutput = "journal"; StandardError = "journal"; }; }; systemd.services.test2 = { serviceConfig.ExecStart = serve 8002 "test2"; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "notify"; StandardOutput = "journal"; StandardError = "journal"; }; }; shb.mitmdump.instances."test1" = { listenPort = 8001; upstreamPort = 8000; after = [ "test1.service" ]; }; shb.mitmdump.instances."test2" = { listenPort = 8003; upstreamPort = 8002; after = [ "test2.service" ]; enabledAddons = [ config.shb.mitmdump.addons.logger ]; extraArgs = [ "--set" "verbose_pattern=/verbose" ]; }; }; testScript = { nodes, ... }: '' start_all() machine.wait_for_unit("test1.service") machine.wait_for_unit("test2.service") machine.wait_for_unit("mitmdump-test1.service") machine.wait_for_unit("mitmdump-test2.service") resp = machine.succeed("curl http://127.0.0.1:8000") print(resp) if resp != "test1/": raise Exception("wanted 'test1'") resp = machine.succeed("curl -v http://127.0.0.1:8001") print(resp) if resp != "test1/": raise Exception("wanted 'test1'") resp = machine.succeed("curl http://127.0.0.1:8002") print(resp) if resp != "test2/": raise Exception("wanted 'test2'") resp = machine.succeed("curl http://127.0.0.1:8003/notverbose") print(resp) if resp != "test2/notverbose": raise Exception("wanted 'test2/notverbose'") resp = machine.succeed("curl http://127.0.0.1:8003/verbose") print(resp) if resp != "test2/verbose": raise Exception("wanted 'test2/verbose'") dump = machine.succeed("journalctl -b -u mitmdump-test1.service") print(dump) if "HTTP/1.0 200 OK" not in dump: raise Exception("expected to see HTTP/1.0 200 OK") if "test1" not in dump: raise Exception("expected to see test1") dump = machine.succeed("journalctl -b -u mitmdump-test2.service") print(dump) if "HTTP/1.0 200 OK" not in dump: raise Exception("expected to see HTTP/1.0 200 OK") if "test2/notverbose" in dump: raise Exception("expected not to see test2/notverbose") if "test2/verbose" not in dump: raise Exception("expected to see test2/verbose") ''; }; } ================================================ FILE: test/blocks/monitoring.nix ================================================ { shb, ... }: let password = "securepw"; oidcSecret = "oidcSecret"; commonTestScript = shb.test.accessScript { hasSSL = { node, ... }: !(isNull node.config.shb.monitoring.ssl); waitForServices = { ... }: [ "grafana.service" ]; waitForPorts = { node, ... }: [ node.config.shb.monitoring.grafanaPort ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/monitoring.nix ]; test = { subdomain = "g"; }; shb.monitoring = { enable = true; inherit (config.test) subdomain domain; scrutiny.enable = false; contactPoints = [ "me@example.com" ]; grafanaPort = 3000; adminPassword.result = config.shb.hardcodedsecret."admin_password".result; secretKey.result = config.shb.hardcodedsecret."secret_key".result; }; shb.hardcodedsecret."admin_password" = { request = config.shb.monitoring.adminPassword.request; settings.content = password; }; shb.hardcodedsecret."secret_key" = { request = config.shb.monitoring.secretKey.request; settings.content = "secret_key_pw"; }; }; https = { config, ... }: { shb.monitoring = { ssl = config.shb.certs.certs.selfsigned.n; }; }; ldap = { config, ... }: { shb.monitoring = { ldap = { userGroup = "user_group"; adminGroup = "admin_group"; }; }; }; clientLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "g"; }; test.login = { startUrl = "https://${config.test.fqdn}"; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('Welcome to Grafana')).to_be_visible()" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('Welcome to Grafana')).to_be_visible()" ]; } { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" # I don't understand why this is not needed. Maybe it keeps somewhere the previous token? "expect(page.get_by_text(re.compile('[Ll]ogin failed'))).to_be_visible(timeout=10000)" ]; } ]; }; }; scrutiny = { lib, ... }: { shb.monitoring = { scrutiny.enable = lib.mkForce true; }; }; clientScrutinyLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "scrutiny"; }; test.login = { startUrl = "https://${config.test.fqdn}"; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ '' if page.get_by_role('button', name=re.compile('Accept')).count() > 0: page.get_by_role('button', name=re.compile('Accept')).click() '' "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('Temperature history for each device')).to_be_visible()" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ '' if page.get_by_role('button', name=re.compile('Accept')).count() > 0: page.get_by_role('button', name=re.compile('Accept')).click() '' "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('Temperature history for each device')).to_be_visible()" ]; } { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" # I don't understand why this is not needed. Maybe it keeps somewhere the previous token? "expect(page.get_by_text(re.compile('[Ll]ogin failed'))).to_be_visible(timeout=10000)" ]; } ]; }; }; sso = { config, ... }: { shb.monitoring = { sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; }; }; shb.hardcodedsecret.oidcSecret = { request = config.shb.monitoring.sso.sharedSecret.request; settings.content = oidcSecret; }; shb.hardcodedsecret.oidcAutheliaSecret = { request = config.shb.monitoring.sso.sharedSecretForAuthelia.request; settings.content = oidcSecret; }; }; in { basic = shb.test.runNixOSTest { name = "monitoring_basic"; node.pkgsReadOnly = false; nodes.server = { imports = [ basic ]; }; nodes.client = { }; testScript = commonTestScript; }; https = shb.test.runNixOSTest { name = "monitoring_https"; node.pkgsReadOnly = false; nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { }; testScript = commonTestScript; }; sso = shb.test.runNixOSTest { name = "monitoring_sso"; node.pkgsReadOnly = false; nodes.client = { imports = [ clientLoginSso ]; virtualisation.memorySize = 4096; }; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; # virtualisation.memorySize = 4096; }; testScript = commonTestScript; }; scrutiny_sso = shb.test.runNixOSTest { name = "monitoring_scrutiny_sso"; node.pkgsReadOnly = false; nodes.client = { imports = [ clientScrutinyLoginSso ]; virtualisation.memorySize = 4096; }; nodes.server = { config, pkgs, ... }: { imports = [ basic scrutiny shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; # virtualisation.memorySize = 4096; }; testScript = commonTestScript; }; } ================================================ FILE: test/blocks/postgresql.nix ================================================ { pkgs, lib, shb, ... }: let pkgs' = pkgs; in { peerWithoutUser = shb.test.runNixOSTest { name = "postgresql-peerWithoutUser"; nodes.machine = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") ../../modules/blocks/postgresql.nix ]; shb.postgresql.ensures = [ { username = "me-with-special-chars"; database = "me-with-special-chars"; } ]; }; testScript = { nodes, ... }: '' start_all() machine.wait_for_unit("postgresql.service") machine.wait_for_open_port(5432) def peer_cmd(user, database): return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database) with subtest("cannot login because of missing user"): machine.fail(peer_cmd("me-with-special-chars", "me-with-special-chars"), timeout=10) with subtest("cannot login with unknown user"): machine.fail(peer_cmd("notme", "me-with-other-chars"), timeout=10) with subtest("cannot login to unknown database"): machine.fail(peer_cmd("me-with-special-chars", "notmine"), timeout=10) ''; }; peerAuth = shb.test.runNixOSTest { name = "postgresql-peerAuth"; nodes.machine = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") ../../modules/blocks/postgresql.nix ]; users.users.me = { isSystemUser = true; group = "me"; extraGroups = [ "sudoers" ]; }; users.groups.me = { }; shb.postgresql.ensures = [ { username = "me"; database = "me"; } ]; }; testScript = { nodes, ... }: '' start_all() machine.wait_for_unit("postgresql.service") machine.wait_for_open_port(5432) def peer_cmd(user, database): return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database) def tcpip_cmd(user, database, port): return "psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port) with subtest("can login with provisioned user and database"): machine.succeed(peer_cmd("me", "me"), timeout=10) with subtest("cannot login with unknown user"): machine.fail(peer_cmd("notme", "me"), timeout=10) with subtest("cannot login to unknown database"): machine.fail(peer_cmd("me", "notmine"), timeout=10) with subtest("cannot login with tcpip"): machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10) ''; }; tcpIPWithoutPasswordAuth = shb.test.runNixOSTest { name = "postgresql-tcpIpWithoutPasswordAuth"; nodes.machine = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") ../../modules/blocks/postgresql.nix ]; shb.postgresql.enableTCPIP = true; shb.postgresql.ensures = [ { username = "me"; database = "me"; } ]; }; testScript = { nodes, ... }: '' start_all() machine.wait_for_unit("postgresql.service") machine.wait_for_open_port(5432) def peer_cmd(user, database): return "sudo -u me psql -U {user} {db} --command \"\"".format(user=user, db=database) def tcpip_cmd(user, database, port): return "psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port) with subtest("cannot login without existing user"): machine.fail(peer_cmd("me", "me"), timeout=10) with subtest("cannot login with user without password"): machine.fail(tcpip_cmd("me", "me", "5432"), timeout=10) ''; }; tcpIPPasswordAuth = let username = "me-with-special-chars"; in shb.test.runNixOSTest { name = "postgresql-tcpIPPasswordAuth"; nodes.machine = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") ../../modules/blocks/postgresql.nix ]; users.users.${username} = { isSystemUser = true; group = username; extraGroups = [ "sudoers" ]; }; users.groups.${username} = { }; system.activationScripts.secret = '' echo secretpw > /run/dbsecret ''; shb.postgresql.enableTCPIP = true; shb.postgresql.ensures = [ { username = username; database = username; passwordFile = "/run/dbsecret"; } ]; }; testScript = { nodes, ... }: '' start_all() machine.wait_for_unit("postgresql.service") machine.wait_for_open_port(5432) def peer_cmd(user, database): return "sudo -u ${username} psql -U {user} {db} --command \"\"".format(user=user, db=database) def tcpip_cmd(user, database, port, password): return "PGPASSWORD={password} psql -h 127.0.0.1 -p {port} -U {user} {db} --command \"\"".format(user=user, db=database, port=port, password=password) with subtest("can peer login with provisioned user and database"): machine.succeed(peer_cmd("${username}", "${username}"), timeout=10) with subtest("can tcpip login with provisioned user and database"): machine.succeed(tcpip_cmd("${username}", "${username}", "5432", "secretpw"), timeout=10) with subtest("cannot tcpip login with wrong password"): machine.fail(tcpip_cmd("${username}", "${username}", "5432", "oops"), timeout=10) ''; }; } ================================================ FILE: test/blocks/restic.nix ================================================ { lib, shb, ... }: let commonTest = user: shb.test.runNixOSTest { name = "restic_backupAndRestore_${user}"; nodes.machine = { config, ... }: { imports = [ shb.test.baseImports ../../modules/blocks/hardcodedsecret.nix ../../modules/blocks/restic.nix ]; shb.hardcodedsecret.A = { request = { owner = "root"; group = "keys"; mode = "0440"; }; settings.content = "secretA"; }; shb.hardcodedsecret.B = { request = { owner = "root"; group = "keys"; mode = "0440"; }; settings.content = "secretB"; }; shb.hardcodedsecret.passphrase = { request = config.shb.restic.instances."testinstance".settings.passphrase.request; settings.content = "secretB"; }; shb.restic.instances."testinstance" = { settings = { enable = true; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = "/opt/repos/A"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "5h"; }; # Those are not needed by the repository but are still included # so we can test them in the hooks section. secrets = { A.source = config.shb.hardcodedsecret.A.result.path; B.source = config.shb.hardcodedsecret.B.result.path; }; }; }; request = { inherit user; sourceDirectories = [ "/opt/files/A" "/opt/files/B" ]; hooks.beforeBackup = [ '' echo $RUNTIME_DIRECTORY if [ "$RUNTIME_DIRECTORY" = /run/restic-backups-testinstance_opt_repos_A ]; then if ! [ -f /run/secrets_restic/restic-backups-testinstance_opt_repos_A ]; then exit 10 fi if [ -z "$A" ] || ! [ "$A" = "secretA" ]; then echo "A:$A" exit 11 fi if [ -z "$B" ] || ! [ "$B" = "secretB" ]; then echo "B:$B" exit 12 fi fi '' ]; }; }; }; extraPythonPackages = p: [ p.dictdiffer ]; skipTypeCheck = true; testScript = { nodes, ... }: let provider = nodes.machine.shb.restic.instances."testinstance"; backupService = provider.result.backupService; restoreScript = provider.result.restoreScript; in '' from dictdiffer import diff def list_files(dir): files_and_content = {} files = machine.succeed(f""" find {dir} -type f """).split("\n")[:-1] for f in files: content = machine.succeed(f""" cat {f} """).strip() files_and_content[f] = content return files_and_content def assert_files(dir, files): result = list(diff(list_files(dir), files)) if len(result) > 0: raise Exception("Unexpected files:", result) with subtest("Create initial content"): machine.succeed(""" mkdir -p /opt/files/A mkdir -p /opt/files/B echo repoA_fileA_1 > /opt/files/A/fileA echo repoA_fileB_1 > /opt/files/A/fileB echo repoB_fileA_1 > /opt/files/B/fileA echo repoB_fileB_1 > /opt/files/B/fileB chown ${user}: -R /opt/files chmod go-rwx -R /opt/files """) assert_files("/opt/files", { '/opt/files/B/fileA': 'repoB_fileA_1', '/opt/files/B/fileB': 'repoB_fileB_1', '/opt/files/A/fileA': 'repoA_fileA_1', '/opt/files/A/fileB': 'repoA_fileB_1', }) with subtest("First backup in repo A"): machine.succeed("systemctl start ${backupService}") with subtest("New content"): machine.succeed(""" echo repoA_fileA_2 > /opt/files/A/fileA echo repoA_fileB_2 > /opt/files/A/fileB echo repoB_fileA_2 > /opt/files/B/fileA echo repoB_fileB_2 > /opt/files/B/fileB """) assert_files("/opt/files", { '/opt/files/B/fileA': 'repoB_fileA_2', '/opt/files/B/fileB': 'repoB_fileB_2', '/opt/files/A/fileA': 'repoA_fileA_2', '/opt/files/A/fileB': 'repoA_fileB_2', }) with subtest("Delete content"): machine.succeed(""" rm -r /opt/files/A /opt/files/B """) assert_files("/opt/files", {}) with subtest("Restore initial content from repo A"): machine.succeed(""" ${restoreScript} restore latest """) assert_files("/opt/files", { '/opt/files/B/fileA': 'repoB_fileA_1', '/opt/files/B/fileB': 'repoB_fileB_1', '/opt/files/A/fileA': 'repoA_fileA_1', '/opt/files/A/fileB': 'repoA_fileB_1', }) ''; }; in { backupAndRestoreRoot = commonTest "root"; backupAndRestoreUser = commonTest "nobody"; } ================================================ FILE: test/blocks/ssl.nix ================================================ { pkgs, shb, ... }: let pkgs' = pkgs; in { test = shb.test.runNixOSTest { name = "ssl-test"; nodes.server = { config, pkgs, ... }: { imports = [ (pkgs'.path + "/nixos/modules/profiles/headless.nix") (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") ../../modules/blocks/ssl.nix ]; users.users = { user1 = { group = "group1"; isSystemUser = true; }; user2 = { group = "group2"; isSystemUser = true; }; }; users.groups = { group1 = { }; group2 = { }; }; shb.certs = { cas.selfsigned = { myca = { name = "My CA"; }; myotherca = { name = "My Other CA"; }; }; certs.selfsigned = { top = { ca = config.shb.certs.cas.selfsigned.myca; domain = "example.com"; group = "nginx"; }; subdomain = { ca = config.shb.certs.cas.selfsigned.myca; domain = "subdomain.example.com"; group = "nginx"; }; multi = { ca = config.shb.certs.cas.selfsigned.myca; domain = "multi1.example.com"; extraDomains = [ "multi2.example.com" "multi3.example.com" ]; group = "nginx"; }; cert1 = { ca = config.shb.certs.cas.selfsigned.myca; domain = "cert1.example.com"; }; cert2 = { ca = config.shb.certs.cas.selfsigned.myca; domain = "cert2.example.com"; group = "group2"; }; }; }; # The configuration below is to create a webserver that uses the server certificate. networking.hosts."127.0.0.1" = [ "example.com" "subdomain.example.com" "wrong.example.com" "multi1.example.com" "multi2.example.com" "multi3.example.com" ]; services.nginx.enable = true; services.nginx.virtualHosts = let mkVirtualHost = response: cert: { onlySSL = true; sslCertificate = cert.paths.cert; sslCertificateKey = cert.paths.key; locations."/".extraConfig = '' add_header Content-Type text/plain; return 200 '${response}'; ''; }; in { "example.com" = mkVirtualHost "Top domain" config.shb.certs.certs.selfsigned.top; "subdomain.example.com" = mkVirtualHost "Subdomain" config.shb.certs.certs.selfsigned.subdomain; "multi1.example.com" = mkVirtualHost "multi1" config.shb.certs.certs.selfsigned.multi; "multi2.example.com" = mkVirtualHost "multi2" config.shb.certs.certs.selfsigned.multi; "multi3.example.com" = mkVirtualHost "multi3" config.shb.certs.certs.selfsigned.multi; }; systemd.services.nginx = { after = [ config.shb.certs.certs.selfsigned.top.systemdService config.shb.certs.certs.selfsigned.subdomain.systemdService config.shb.certs.certs.selfsigned.multi.systemdService config.shb.certs.certs.selfsigned.cert1.systemdService config.shb.certs.certs.selfsigned.cert2.systemdService ]; requires = [ config.shb.certs.certs.selfsigned.top.systemdService config.shb.certs.certs.selfsigned.subdomain.systemdService config.shb.certs.certs.selfsigned.multi.systemdService config.shb.certs.certs.selfsigned.cert1.systemdService config.shb.certs.certs.selfsigned.cert2.systemdService ]; }; }; # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix testScript = { nodes, ... }: let myca = nodes.server.shb.certs.cas.selfsigned.myca; myotherca = nodes.server.shb.certs.cas.selfsigned.myotherca; top = nodes.server.shb.certs.certs.selfsigned.top; subdomain = nodes.server.shb.certs.certs.selfsigned.subdomain; multi = nodes.server.shb.certs.certs.selfsigned.multi; cert1 = nodes.server.shb.certs.certs.selfsigned.cert1; cert2 = nodes.server.shb.certs.certs.selfsigned.cert2; in '' start_all() # Make sure certs are generated. server.wait_for_file("${myca.paths.key}") server.wait_for_file("${myca.paths.cert}") server.wait_for_file("${myotherca.paths.key}") server.wait_for_file("${myotherca.paths.cert}") server.wait_for_file("${top.paths.key}") server.wait_for_file("${top.paths.cert}") server.wait_for_file("${subdomain.paths.key}") server.wait_for_file("${subdomain.paths.cert}") server.wait_for_file("${multi.paths.key}") server.wait_for_file("${multi.paths.cert}") server.wait_for_file("${cert1.paths.key}") server.wait_for_file("${cert1.paths.cert}") server.wait_for_file("${cert2.paths.key}") server.wait_for_file("${cert2.paths.cert}") server.require_unit_state("${nodes.server.shb.certs.systemdService}", "inactive") server.wait_for_unit("nginx") server.wait_for_open_port(443) def assert_owner(path, user, group): owner = server.succeed("stat --format '%U:%G' {}".format(path)).strip(); want_owner = user + ":" + group if owner != want_owner: raise Exception('Unexpected owner for {}: wanted "{}", got: "{}"'.format(path, want_owner, owner)) def assert_perm(path, want_perm): perm = server.succeed("stat --format '%a' {}".format(path)).strip(); if perm != want_perm: raise Exception('Unexpected perm for {}: wanted "{}", got: "{}"'.format(path, want_perm, perm)) with subtest("Certificates content seem correct"): myca_key = server.succeed("cat {}".format("${myca.paths.key}")).strip(); myca_cert = server.succeed("cat {}".format("${myca.paths.cert}")).strip(); cert1_key = server.succeed("cat {}".format("${cert1.paths.key}")).strip(); cert1_cert = server.succeed("cat {}".format("${cert1.paths.cert}")).strip(); cert2_key = server.succeed("cat {}".format("${cert2.paths.key}")).strip(); cert2_cert = server.succeed("cat {}".format("${cert2.paths.cert}")).strip(); ca_bundle = server.succeed("cat /etc/ssl/certs/ca-bundle.crt").strip(); if myca_cert == "": raise Exception("CA cert was empty") if cert1_key == "": raise Exception("Cert1 key was empty") if cert1_cert == "": raise Exception("Cert1 cert was empty") if cert2_key == "": raise Exception("Cert2 key was empty") if cert2_cert == "": raise Exception("Cert2 cert was empty") if cert1_key == cert2_key: raise Exception("Cert1 key and cert2 key are the same") if cert1_cert == cert2_cert: raise Exception("Cert1 cert and cert2 cert are the same") if ca_bundle == "": raise Exception("CA bundle was empty") with subtest("Certificate is trusted in curl"): resp = server.succeed("curl --fail-with-body -v https://example.com") if resp != "Top domain": raise Exception('Unexpected response, got: {}'.format(resp)) resp = server.succeed("curl --fail-with-body -v https://subdomain.example.com") if resp != "Subdomain": raise Exception('Unexpected response, got: {}'.format(resp)) resp = server.succeed("curl --fail-with-body -v https://multi1.example.com") if resp != "multi1": raise Exception('Unexpected response, got: {}'.format(resp)) resp = server.succeed("curl --fail-with-body -v https://multi2.example.com") if resp != "multi2": raise Exception('Unexpected response, got: {}'.format(resp)) resp = server.succeed("curl --fail-with-body -v https://multi3.example.com") if resp != "multi3": raise Exception('Unexpected response, got: {}'.format(resp)) with subtest("Certificate has correct permission"): assert_owner("${cert1.paths.key}", "root", "root") assert_owner("${cert1.paths.cert}", "root", "root") assert_perm("${cert1.paths.key}", "640") assert_perm("${cert1.paths.cert}", "640") assert_owner("${cert2.paths.key}", "root", "group2") assert_owner("${cert2.paths.cert}", "root", "group2") assert_perm("${cert2.paths.key}", "640") assert_perm("${cert2.paths.cert}", "640") with subtest("Certificates content seem correct"): if cert1_key == "": raise Exception("Cert1 key was empty") if cert1_cert == "": raise Exception("Cert1 cert was empty") if cert2_key == "": raise Exception("Cert2 key was empty") if cert2_cert == "": raise Exception("Cert2 cert was empty") if cert1_key == cert2_key: raise Exception("Cert1 key and cert2 key are the same") if cert1_cert == cert2_cert: raise Exception("Cert1 cert and cert2 cert are the same") with subtest("Fail if certificate is not in CA bundle"): server.fail("curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://example.com") server.fail("curl --cacert /etc/static/ssl/certs/ca-bundle.crt --fail-with-body -v https://subdomain.example.com") server.fail("curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://example.com") server.fail("curl --cacert /etc/static/ssl/certs/ca-certificates.crt --fail-with-body -v https://subdomain.example.com") with subtest("Idempotency"): server.succeed("systemctl restart shb-certs-ca-myca") server.succeed("systemctl restart shb-certs-cert-selfsigned-cert1") server.succeed("systemctl restart shb-certs-cert-selfsigned-cert2") new_myca_key = server.succeed("cat {}".format("${myca.paths.key}")).strip(); new_myca_cert = server.succeed("cat {}".format("${myca.paths.cert}")).strip(); new_cert1_key = server.succeed("cat {}".format("${cert1.paths.key}")).strip(); new_cert1_cert = server.succeed("cat {}".format("${cert1.paths.cert}")).strip(); new_cert2_key = server.succeed("cat {}".format("${cert2.paths.key}")).strip(); new_cert2_cert = server.succeed("cat {}".format("${cert2.paths.cert}")).strip(); new_ca_bundle = server.succeed("cat /etc/ssl/certs/ca-bundle.crt").strip(); if new_myca_key != myca_key: raise Exception("New CA key is different from old one.") if new_myca_cert != myca_cert: raise Exception("New CA cert is different from old one.") if new_cert1_key != cert1_key: raise Exception("New Cert1 key is different from old one.") if new_cert1_cert != cert1_cert: raise Exception("New Cert1 cert is different from old one.") if new_cert2_key != cert2_key: raise Exception("New Cert2 key is different from old one.") if new_cert2_cert != cert2_cert: raise Exception("New Cert2 cert is different from old one.") if new_ca_bundle != ca_bundle: raise Exception("New CA bundle is different from old one.") ''; }; } ================================================ FILE: test/common.nix ================================================ { pkgs, lib }: let inherit (lib) hasAttr mkOption optionalString; inherit (lib.types) bool enum listOf nullOr submodule str ; baseImports = { imports = [ (pkgs.path + "/nixos/modules/profiles/headless.nix") (pkgs.path + "/nixos/modules/profiles/qemu-guest.nix") ]; }; accessScript = lib.makeOverridable ( { hasSSL, waitForServices ? s: [ ], waitForPorts ? p: [ ], waitForUnixSocket ? u: [ ], waitForUrls ? u: [ ], extraScript ? { ... }: "", redirectSSO ? false, }: { nodes, ... }: let cfg = nodes.server.test; fqdn = "${cfg.subdomain}.${cfg.domain}"; proto_fqdn = if hasSSL args then "https://${fqdn}" else "http://${fqdn}"; args = { node.name = "server"; node.config = nodes.server; inherit fqdn proto_fqdn; }; autheliaEnabled = (hasAttr "authelia" nodes.server.shb) && nodes.server.shb.authelia.enable; lldapEnabled = (hasAttr "lldap" nodes.server.shb) && nodes.server.shb.lldap.enable; in '' import json import os import pathlib start_all() def curl(target, format, endpoint, data="", extra=""): cmd = ("curl --show-error --location" + " --cookie-jar cookie.txt" + " --cookie cookie.txt" + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" # Client must be able to resolve talking to auth server + " --connect-to auth.${cfg.domain}:443:server:443" + (f" --data '{data}'" if data != "" else "") + (f" --silent --output /dev/null --write-out '{format}'" if format != "" else "") + (f" {extra}" if extra != "" else "") + f" {endpoint}") print(cmd) _, r = target.execute(cmd) print(r) try: return json.loads(r) except: return r def unline_with(j, s): return j.join((x.strip() for x in s.split("\n"))) '' + lib.strings.concatMapStrings (s: ''server.wait_for_unit("${s}")'' + "\n") ( waitForServices args ++ (lib.optionals autheliaEnabled [ "authelia-auth.${cfg.domain}.service" ]) ++ (lib.optionals lldapEnabled [ "lldap.service" ]) ) + lib.strings.concatMapStrings (p: "server.wait_for_open_port(${toString p})" + "\n") ( waitForPorts args # TODO: when the SSO block exists, replace this hardcoded port. ++ (lib.optionals autheliaEnabled [ 9091 # nodes.server.services.authelia.instances."auth.${domain}".settings.server.port ]) ) + lib.strings.concatMapStrings (u: ''server.wait_for_open_unix_socket("${u}")'' + "\n") ( waitForUnixSocket args ) + '' if ${if hasSSL args then "True" else "False"}: server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt") client.succeed("rm -r /etc/ssl/certs") client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt") '' # Making a curl request to an URL needs to happen after we copied the certificates over, # otherwise curl will not be able to verify the "legitimacy of the server". + lib.strings.concatMapStrings ( u: let url = if builtins.isString u then u else u.url; status = if builtins.isString u then 200 else u.status; in '' import time done = False count = 15 while not done and count > 0: response = curl(client, """{"code":%{response_code}}""", "${url}") time.sleep(5) count -= 1 if isinstance(response, dict): done = response.get('code') == ${toString status} if not done: raise Exception(f"Response was never ${toString status}, got last: {response}") '' + "\n" ) (waitForUrls args) + ( if (!redirectSSO) then '' with subtest("access"): response = curl(client, """{"code":%{response_code}}""", "${proto_fqdn}") if response['code'] != 200: raise Exception(f"Code is {response['code']}") '' else '' with subtest("unauthenticated access is not granted"): response = curl(client, """{"code":%{response_code},"auth_host":"%{urle.host}","auth_query":"%{urle.query}","all":%{json}}""", "${proto_fqdn}") if response['code'] != 200: raise Exception(f"Code is {response['code']}") if response['auth_host'] != "auth.${cfg.domain}": raise Exception(f"auth host should be auth.${cfg.domain} but is {response['auth_host']}") if response['auth_query'] != "rd=${proto_fqdn}/": raise Exception(f"auth query should be rd=${proto_fqdn}/ but is {response['auth_query']}") '' ) + ( let script = extraScript args; in lib.optionalString (script != "") script ) + (optionalString (hasAttr "test" nodes.server && hasAttr "login" nodes.server.test) '' with subtest("Login from server"): code, logs = server.execute("login_playwright") print(logs) try: server.copy_from_vm("trace") except: print("No trace found on server") # if code != 0: # raise Exception("login_playwright did not succeed") '') + (optionalString (hasAttr "test" nodes.client && hasAttr "login" nodes.client.test) '' with subtest("Login from client"): code, logs = client.execute("login_playwright") print(logs) try: client.copy_from_vm("trace") except: print("No trace found on client") # if code != 0: # raise Exception("login_playwright did not succeed") '') ); backupScript = args: (accessScript args).override { extraScript = { proto_fqdn, ... }: '' with subtest("backup"): server.succeed("systemctl start restic-backups-testinstance_opt_repos_A") ''; }; in { inherit baseImports accessScript; runNixOSTest = args: pkgs.testers.runNixOSTest ( { interactive.sshBackdoor.enable = true; } // args ); mkScripts = args: { access = accessScript args; backup = backupScript args; }; baseModule = { config, ... }: { options.test = { domain = mkOption { type = str; default = "example.com"; }; subdomain = mkOption { type = str; }; fqdn = mkOption { type = str; readOnly = true; default = "${config.test.subdomain}.${config.test.domain}"; }; hasSSL = mkOption { type = bool; default = false; }; proto = mkOption { type = str; readOnly = true; default = if config.test.hasSSL then "https" else "http"; }; proto_fqdn = mkOption { type = str; readOnly = true; default = "${config.test.proto}://${config.test.fqdn}"; }; }; imports = [ baseImports ../modules/blocks/hardcodedsecret.nix ../modules/blocks/nginx.nix ]; config = { # HTTP(s) server port. networking.firewall.allowedTCPPorts = [ 80 443 ]; shb.nginx.accessLog = true; networking.hosts = { "192.168.1.2" = [ config.test.fqdn "auth.${config.test.domain}" ]; }; }; }; clientLoginModule = { config, pkgs, ... }: let cfg = config.test.login; in { options.test.login = { browser = mkOption { type = enum [ "firefox" "chromium" "webkit" ]; default = "firefox"; }; usernameFieldLabelRegex = mkOption { type = str; default = "[Uu]sername"; }; usernameFieldSelector = mkOption { type = str; default = "get_by_label(re.compile('${cfg.usernameFieldLabelRegex}'))"; }; passwordFieldLabelRegex = mkOption { type = str; default = "[Pp]assword"; }; passwordFieldSelector = mkOption { type = str; default = "get_by_label(re.compile('${cfg.passwordFieldLabelRegex}'))"; }; loginButtonNameRegex = mkOption { type = str; default = "[Ll]ogin"; }; loginSpawnsNewPage = mkOption { type = bool; default = false; }; testLoginWith = mkOption { type = listOf (submodule { options = { username = mkOption { type = nullOr str; default = null; }; password = mkOption { type = nullOr str; default = null; }; nextPageExpect = mkOption { type = listOf str; }; }; }); }; startUrl = mkOption { type = str; default = "http://${config.test.fqdn}"; }; beforeHook = mkOption { type = str; default = ""; }; }; config = { networking.hosts = { "192.168.1.2" = [ config.test.fqdn "auth.${config.test.domain}" ]; }; environment.variables = { PLAYWRIGHT_BROWSERS_PATH = pkgs.playwright-driver.browsers; }; environment.systemPackages = [ (pkgs.writers.writePython3Bin "login_playwright" { libraries = [ pkgs.python3Packages.playwright ]; flakeIgnore = [ "F401" "E501" ]; } ( let testCfg = pkgs.writeText "users.json" (builtins.toJSON cfg); in '' import json import re import sys from playwright.sync_api import expect from playwright.sync_api import sync_playwright browsers = { "chromium": {'args': ["--headless", "--disable-gpu"], 'channel': 'chromium'}, "firefox": {'args': ["--reporter", "html"]}, "webkit": {}, } with open("${testCfg}") as f: testCfg = json.load(f) print("Test configuration:") print(json.dumps(testCfg, indent=2)) browser_name = testCfg['browser'] browser_args = browsers.get(browser_name) print(f"Running test on {browser_name} {' '.join(browser_args)}") with sync_playwright() as p: browser = getattr(p, browser_name).launch(**browser_args) for i, u in enumerate(testCfg["testLoginWith"]): print(f"Testing for user {u['username']} and password {u['password']}") context = browser.new_context(ignore_https_errors=True) context.set_default_navigation_timeout(2 * 60 * 1000) context.tracing.start(screenshots=True, snapshots=True, sources=True) try: page = context.new_page() # This is used to debug frame changes. # Frame changes or popup are somewhat handled with the expect_page() call later. page.on("framenavigated", lambda frame: print("NAV:", frame.url)) page.on("frameattached", lambda frame: print("ATTACHED:", frame.url)) page.on("framedetached", lambda frame: print("DETACHED:", frame.url)) print(f"Going to {testCfg['startUrl']}") page.goto(testCfg['startUrl']) if testCfg.get("beforeHook") is not None: if testCfg['loginSpawnsNewPage']: print("Login spawns new page") # The with clause handles window.open() or . with context.expect_page() as p: exec(testCfg.get("beforeHook")) page = p.value else: exec(testCfg.get("beforeHook")) if u['username'] is not None: print(f"Filling field username with {u['username']}") page.${cfg.usernameFieldSelector}.fill(u['username']) if u['password'] is not None: print(f"Filling field password with {u['password']}") page.${cfg.passwordFieldSelector}.fill(u['password']) # Assumes we don't need to login, so skip this. if u['username'] is not None or u['password'] is not None: print(f"Clicking button {testCfg['loginButtonNameRegex']}") page.get_by_role("button", name=re.compile(testCfg['loginButtonNameRegex'])).click() for line in u['nextPageExpect']: print(f"Running: {line}") print(f"Page has title: {page.title()}") exec(line) finally: print(f'Saving trace at trace/{i}.zip') context.tracing.stop(path=f"trace/{i}.zip") browser.close() '' ) ) ]; }; }; backup = backupOption: { config, ... }: { imports = [ ../modules/blocks/restic.nix ]; shb.restic.instances."testinstance" = { request = backupOption.request; settings = { enable = true; passphrase.result = config.shb.hardcodedsecret.backupPassphrase.result; repository = { path = "/opt/repos/A"; timerConfig = { OnCalendar = "00:00:00"; RandomizedDelaySec = "5h"; }; }; }; }; shb.hardcodedsecret.backupPassphrase = { request = config.shb.restic.instances."testinstance".settings.passphrase.request; settings.content = "PassPhrase"; }; }; certs = { config, ... }: { imports = [ ../modules/blocks/ssl.nix ]; shb.certs = { cas.selfsigned.myca = { name = "My CA"; }; certs.selfsigned = { n = { ca = config.shb.certs.cas.selfsigned.myca; domain = "*.${config.test.domain}"; group = "nginx"; }; }; }; systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ]; systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; }; ldap = { config, pkgs, ... }: { imports = [ ../modules/blocks/lldap.nix ]; networking.hosts = { "127.0.0.1" = [ "ldap.${config.test.domain}" ]; }; shb.hardcodedsecret.ldapUserPassword = { request = config.shb.lldap.ldapUserPassword.request; settings.content = "ldapUserPassword"; }; shb.hardcodedsecret.jwtSecret = { request = config.shb.lldap.jwtSecret.request; settings.content = "jwtSecrets"; }; shb.lldap = { enable = true; inherit (config.test) domain; subdomain = "ldap"; ldapPort = 3890; webUIListenPort = 17170; dcdomain = "dc=example,dc=com"; ldapUserPassword.result = config.shb.hardcodedsecret.ldapUserPassword.result; jwtSecret.result = config.shb.hardcodedsecret.jwtSecret.result; debug = false; # Enable this if needed, but beware it is _very_ verbose. ensureUsers = { alice = { email = "alice@example.com"; groups = [ "user_group" ]; password.result.path = pkgs.writeText "alicePassword" "AlicePassword"; }; bob = { email = "bob@example.com"; # Purposely not adding bob to the user_group # so we can make sure users only part admins # can also login normally. groups = [ "admin_group" ]; password.result.path = pkgs.writeText "bobPassword" "BobPassword"; }; charlie = { email = "charlie@example.com"; groups = [ "other_group" ]; password.result.path = pkgs.writeText "charliePassword" "CharliePassword"; }; }; ensureGroups = { user_group = { }; admin_group = { }; other_group = { }; }; }; }; sso = ssl: { config, pkgs, ... }: { imports = [ ../modules/blocks/authelia.nix ]; networking.hosts = { "127.0.0.1" = [ "${config.shb.authelia.subdomain}.${config.shb.authelia.domain}" ]; }; shb.authelia = { enable = true; inherit (config.test) domain; subdomain = "auth"; ssl = config.shb.certs.certs.selfsigned.n; debug = true; ldapHostname = "127.0.0.1"; ldapPort = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; secrets = { jwtSecret.result = config.shb.hardcodedsecret.autheliaJwtSecret.result; ldapAdminPassword.result = config.shb.hardcodedsecret.ldapAdminPassword.result; sessionSecret.result = config.shb.hardcodedsecret.sessionSecret.result; storageEncryptionKey.result = config.shb.hardcodedsecret.storageEncryptionKey.result; identityProvidersOIDCHMACSecret.result = config.shb.hardcodedsecret.identityProvidersOIDCHMACSecret.result; identityProvidersOIDCIssuerPrivateKey.result = config.shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey.result; }; }; shb.hardcodedsecret.autheliaJwtSecret = { request = config.shb.authelia.secrets.jwtSecret.request; settings.content = "jwtSecret"; }; shb.hardcodedsecret.ldapAdminPassword = { request = config.shb.authelia.secrets.ldapAdminPassword.request; settings.content = "ldapUserPassword"; }; shb.hardcodedsecret.sessionSecret = { request = config.shb.authelia.secrets.sessionSecret.request; settings.content = "sessionSecret"; }; shb.hardcodedsecret.storageEncryptionKey = { request = config.shb.authelia.secrets.storageEncryptionKey.request; settings.content = "storageEncryptionKey"; }; shb.hardcodedsecret.identityProvidersOIDCHMACSecret = { request = config.shb.authelia.secrets.identityProvidersOIDCHMACSecret.request; settings.content = "identityProvidersOIDCHMACSecret"; }; shb.hardcodedsecret.identityProvidersOIDCIssuerPrivateKey = { request = config.shb.authelia.secrets.identityProvidersOIDCIssuerPrivateKey.request; settings.source = (pkgs.runCommand "gen-private-key" { } '' mkdir $out ${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096 '') + "/private.pem"; }; }; } ================================================ FILE: test/contracts/backup.nix ================================================ { shb, ... }: { restic_root = shb.contracts.test.backup { name = "restic_root"; username = "root"; providerRoot = [ "shb" "restic" "instances" "mytest" ]; modules = [ ../../modules/blocks/restic.nix ../../modules/blocks/hardcodedsecret.nix ]; settings = { repository, config, ... }: { enable = true; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = repository; timerConfig = { OnCalendar = "00:00:00"; }; }; }; extraConfig = { username, config, ... }: { shb.hardcodedsecret.passphrase = { request = config.shb.restic.instances."mytest".settings.passphrase.request; settings.content = "passphrase"; }; }; }; restic_nonroot = shb.contracts.test.backup { name = "restic_nonroot"; username = "me"; providerRoot = [ "shb" "restic" "instances" "mytest" ]; modules = [ ../../modules/blocks/restic.nix ../../modules/blocks/hardcodedsecret.nix ]; settings = { repository, config, ... }: { enable = true; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = repository; timerConfig = { OnCalendar = "00:00:00"; }; }; }; extraConfig = { username, config, ... }: { shb.hardcodedsecret.passphrase = { request = config.shb.restic.instances."mytest".settings.passphrase.request; settings.content = "passphrase"; }; }; }; borgbackup_root = shb.contracts.test.backup { name = "borgbackup_root"; username = "root"; providerRoot = [ "shb" "borgbackup" "instances" "mytest" ]; modules = [ ../../modules/blocks/borgbackup.nix ../../modules/blocks/hardcodedsecret.nix ]; settings = { repository, config, ... }: { enable = true; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = repository; timerConfig = { OnCalendar = "00:00:00"; }; }; }; extraConfig = { username, config, ... }: { shb.hardcodedsecret.passphrase = { request = config.shb.borgbackup.instances."mytest".settings.passphrase.request; settings.content = "passphrase"; }; }; }; borgbackup_nonroot = shb.contracts.test.backup { name = "borgbackup_nonroot"; username = "me"; providerRoot = [ "shb" "borgbackup" "instances" "mytest" ]; modules = [ ../../modules/blocks/borgbackup.nix ../../modules/blocks/hardcodedsecret.nix ]; settings = { repository, config, ... }: { enable = true; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = repository; timerConfig = { OnCalendar = "00:00:00"; }; }; }; extraConfig = { username, config, ... }: { shb.hardcodedsecret.passphrase = { request = config.shb.borgbackup.instances."mytest".settings.passphrase.request; settings.content = "passphrase"; }; }; }; } ================================================ FILE: test/contracts/databasebackup.nix ================================================ { shb, ... }: { restic_postgres = shb.contracts.test.databasebackup { name = "restic_postgres"; requesterRoot = [ "shb" "postgresql" "databasebackup" ]; providerRoot = [ "shb" "restic" "databases" "postgresql" ]; modules = [ ../../modules/blocks/postgresql.nix ../../modules/blocks/restic.nix ../../modules/blocks/hardcodedsecret.nix ]; settings = { repository, config, ... }: { enable = true; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = repository; timerConfig = { OnCalendar = "00:00:00"; }; }; }; extraConfig = { config, database, ... }: { shb.postgresql.ensures = [ { inherit database; username = database; } ]; shb.hardcodedsecret.passphrase = { request = config.shb.restic.databases.postgresql.settings.passphrase.request; settings.content = "passphrase"; }; }; }; borgbackup_postgres = shb.contracts.test.databasebackup { name = "borgbackup_postgres"; requesterRoot = [ "shb" "postgresql" "databasebackup" ]; providerRoot = [ "shb" "borgbackup" "databases" "postgresql" ]; modules = [ ../../modules/blocks/postgresql.nix ../../modules/blocks/borgbackup.nix ../../modules/blocks/hardcodedsecret.nix ]; settings = { repository, config, ... }: { enable = true; stateDir = "/var/lib/borgbackup_postgres"; passphrase.result = config.shb.hardcodedsecret.passphrase.result; repository = { path = repository; timerConfig = { OnCalendar = "00:00:00"; }; }; }; extraConfig = { config, database, ... }: { shb.postgresql.ensures = [ { inherit database; username = database; } ]; shb.hardcodedsecret.passphrase = { request = config.shb.borgbackup.databases.postgresql.settings.passphrase.request; settings.content = "passphrase"; }; }; }; } ================================================ FILE: test/contracts/secret/sops.yaml ================================================ ================================================ FILE: test/contracts/secret.nix ================================================ { shb, ... }: { hardcoded_root_root = shb.contracts.test.secret { name = "hardcoded"; modules = [ ../../modules/blocks/hardcodedsecret.nix ]; configRoot = [ "shb" "hardcodedsecret" ]; settingsCfg = secret: { content = secret; }; }; hardcoded_user_group = shb.contracts.test.secret { name = "hardcoded"; modules = [ ../../modules/blocks/hardcodedsecret.nix ]; configRoot = [ "shb" "hardcodedsecret" ]; settingsCfg = secret: { content = secret; }; owner = "user"; group = "group"; mode = "640"; }; # TODO: how to do this? # sops = shb.contracts.test.secret { # name = "sops"; # configRoot = cfg: name: cfg.sops.secrets.${name}; # createContent = content: { # sopsFile = ./secret/sops.yaml; # }; # }; } ================================================ FILE: test/modules/davfs.nix ================================================ { pkgs, lib, ... }: let anyOpt = default: lib.mkOption { type = lib.types.anything; inherit default; }; testConfig = m: let cfg = (lib.evalModules { specialArgs = { inherit pkgs; }; modules = [ { options = { systemd = anyOpt { }; services = anyOpt { }; }; } ../../modules/blocks/davfs.nix m ]; }).config; in { inherit (cfg) systemd services; }; in { testDavfsNoOptions = { expected = { services.davfs2.enable = false; systemd.mounts = [ ]; }; expr = testConfig { }; }; } ================================================ FILE: test/modules/homepage.nix ================================================ { shb }: { testHomepageAsServiceGroup = { expected = [ { "Media" = [ { "Jellyfin" = { "href" = "https://example.com/jellyfin"; "icon" = "sh-jellyfin"; "siteMonitor" = "http://127.0.0.1:8096"; }; } ]; } ]; expr = shb.homepage.asServiceGroup { Media = { services = { Jellyfin = { dashboard.request = { externalUrl = "https://example.com/jellyfin"; internalUrl = "http://127.0.0.1:8096"; }; apiKey = null; }; }; }; }; }; testHomepageAsServiceGroupApiKey = { expected = [ { "Media" = [ { "Jellyfin" = { "href" = "https://example.com/jellyfin"; "icon" = "sh-jellyfin"; "siteMonitor" = "http://127.0.0.1:8096"; "widget" = { "key" = "{{HOMEPAGE_FILE_Media_Jellyfin}}"; "password" = "{{HOMEPAGE_FILE_Media_Jellyfin}}"; "type" = "jellyfin"; "url" = "http://127.0.0.1:8096"; }; }; } ]; } ]; expr = shb.homepage.asServiceGroup { Media = { services = { Jellyfin = { dashboard.request = { externalUrl = "https://example.com/jellyfin"; internalUrl = "http://127.0.0.1:8096"; }; apiKey.result.path = "path_D"; }; }; }; }; }; testHomepageAsServiceGroupNoServiceMonitor = { expected = [ { "Media" = [ { "Jellyfin" = { "href" = "https://example.com/jellyfin"; "icon" = "sh-jellyfin"; "siteMonitor" = null; }; } ]; } ]; expr = shb.homepage.asServiceGroup { Media = { services = { Jellyfin = { dashboard.request = { externalUrl = "https://example.com/jellyfin"; internalUrl = null; }; apiKey = null; }; }; }; }; }; testHomepageAsServiceGroupOverride = { expected = [ { "Media" = [ { "Jellyfin" = { "href" = "https://example.com/jellyfin"; "icon" = "sh-icon"; "siteMonitor" = "http://127.0.0.1:8096"; }; } ]; } ]; expr = shb.homepage.asServiceGroup { Media = { services = { Jellyfin = { dashboard.request = { externalUrl = "https://example.com/jellyfin"; internalUrl = "http://127.0.0.1:8096"; }; settings = { icon = "sh-icon"; }; apiKey = null; }; }; }; }; }; testHomepageAsServiceGroupSortOrder = { expected = [ { "C" = [ ]; } { "A" = [ ]; } { "B" = [ ]; } ]; expr = shb.homepage.asServiceGroup { A = { sortOrder = 2; services = { }; }; B = { sortOrder = 3; services = { }; }; C = { sortOrder = 1; services = { }; }; }; }; testHomepageAsServiceServicesSortOrder = { expected = [ { "Media" = [ { "A" = { "href" = "https://example.com/a"; "icon" = "sh-a"; "siteMonitor" = null; }; } { "C" = { "href" = "https://example.com/c"; "icon" = "sh-c"; "siteMonitor" = null; }; } { "B" = { "href" = "https://example.com/b"; "icon" = "sh-b"; "siteMonitor" = null; }; } ]; } ]; expr = shb.homepage.asServiceGroup { Media = { sortOrder = null; services = { A = { sortOrder = 1; dashboard.request = { externalUrl = "https://example.com/a"; internalUrl = null; }; apiKey = null; }; B = { sortOrder = 3; dashboard.request = { externalUrl = "https://example.com/b"; internalUrl = null; }; apiKey = null; }; C = { sortOrder = 2; dashboard.request = { externalUrl = "https://example.com/c"; internalUrl = null; }; apiKey = null; }; }; }; }; }; testHomepageAllKeys = { expected = { "A_A" = "path_A"; "A_B" = "path_B"; "B_D" = "path_D"; }; expr = shb.homepage.allKeys { A = { sortOrder = 1; services = { A = { sortOrder = 1; dashboard.request = { externalUrl = "https://example.com/a"; internalUrl = null; }; apiKey.result.path = "path_A"; }; B = { sortOrder = 2; dashboard.request = { externalUrl = "https://example.com/b"; internalUrl = null; }; apiKey.result.path = "path_B"; }; }; }; B = { sortOrder = 2; services = { C = { sortOrder = 1; dashboard.request = { externalUrl = "https://example.com/a"; internalUrl = null; }; apiKey = null; }; D = { sortOrder = 2; dashboard.request = { externalUrl = "https://example.com/b"; internalUrl = null; }; apiKey.result.path = "path_D"; }; }; }; }; }; } ================================================ FILE: test/modules/lib.nix ================================================ { lib, shb, ... }: let inherit (lib) nameValuePair; in { # Tests that withReplacements can: # - recurse in attrs and lists # - .source field is understood # - .transform field is understood # - if .source field is found, ignores other fields testLibWithReplacements = { expected = let item = root: { a = "A"; b = "%SECRET_${root}B%"; c = "%SECRET_${root}C%"; }; in (item "") // { nestedAttr = item "NESTEDATTR_"; nestedList = [ (item "NESTEDLIST_0_") ]; doubleNestedList = [ { n = (item "DOUBLENESTEDLIST_0_N_"); } ]; }; expr = let item = { a = "A"; b.source = "/path/B"; b.transform = null; c.source = "/path/C"; c.transform = v: "prefix-${v}-suffix"; c.other = "other"; }; in shb.withReplacements ( item // { nestedAttr = item; nestedList = [ item ]; doubleNestedList = [ { n = item; } ]; } ); }; testLibWithReplacementsRootList = { expected = let item = root: { a = "A"; b = "%SECRET_${root}B%"; c = "%SECRET_${root}C%"; }; in [ (item "0_") (item "1_") [ (item "2_0_") ] [ { n = (item "3_0_N_"); } ] ]; expr = let item = { a = "A"; b.source = "/path/B"; b.transform = null; c.source = "/path/C"; c.transform = v: "prefix-${v}-suffix"; c.other = "other"; }; in shb.withReplacements [ item item [ item ] [ { n = item; } ] ]; }; testLibGetReplacements = { expected = let secrets = root: [ (nameValuePair "%SECRET_${root}B%" "$(cat /path/B)") (nameValuePair "%SECRET_${root}C%" "prefix-$(cat /path/C)-suffix") ]; in (secrets "") ++ (secrets "DOUBLENESTEDLIST_0_N_") ++ (secrets "NESTEDATTR_") ++ (secrets "NESTEDLIST_0_"); expr = let item = { a = "A"; b.source = "/path/B"; b.transform = null; c.source = "/path/C"; c.transform = v: "prefix-${v}-suffix"; c.other = "other"; }; in map shb.genReplacement ( shb.getReplacements ( item // { nestedAttr = item; nestedList = [ item ]; doubleNestedList = [ { n = item; } ]; } ) ); }; testParseXML = { expected = { "a" = { "b" = "1"; "c" = { "d" = "1"; }; }; }; expr = shb.parseXML '' 1 1 ''; }; } // import ./homepage.nix { inherit shb; } ================================================ FILE: test/services/arr.nix ================================================ { pkgs, lib, shb, ... }: let healthUrl = "/health"; loginUrl = "/UI/Login"; # TODO: Test login commonTestScript = appname: cfgPathFn: shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.arr.${appname}.ssl); waitForServices = { ... }: [ "${appname}.service" "nginx.service" ]; waitForPorts = { node, ... }: [ node.config.shb.arr.${appname}.settings.Port ]; extraScript = { node, fqdn, proto_fqdn, ... }: let shbapp = node.config.shb.arr.${appname}; cfgPath = cfgPathFn shbapp; apiKey = if (shbapp.settings ? ApiKey) then "01234567890123456789" else null; in '' # These curl requests still return a 200 even with sso redirect. with subtest("health"): response = curl(client, """{"code":%{response_code}}""", "${fqdn}${healthUrl}") print("response =", response) if response['code'] != 200: raise Exception(f"Code is {response['code']}") with subtest("login"): response = curl(client, """{"code":%{response_code}}""", "${fqdn}${loginUrl}") if response['code'] != 200: raise Exception(f"Code is {response['code']}") '' + lib.optionalString (apiKey != null && cfgPath != null) '' with subtest("apikey"): config = server.succeed("cat ${cfgPath}") if "${apiKey}" not in config: raise Exception(f"Unexpected API Key. Want '${apiKey}', got '{config}'") ''; }; basic = appname: { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/arr.nix ]; test = { subdomain = appname; }; shb.arr.${appname} = { enable = true; inherit (config.test) subdomain domain; settings.ApiKey.source = pkgs.writeText "APIKey" "01234567890123456789"; # Needs to be >=20 characters. }; }; clientLogin = appname: { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = appname; }; test.login = { startUrl = "http://${config.test.fqdn}"; usernameFieldLabelRegex = "[Uu]sername"; passwordFieldLabelRegex = "^ *[Pp]assword"; loginButtonNameRegex = "[Ll]og [Ii]n"; testLoginWith = [ { nextPageExpect = [ "expect(page).to_have_title(re.compile('${appname}', re.IGNORECASE))" ]; } ]; }; }; basicTest = appname: cfgPathFn: shb.test.runNixOSTest { name = "arr_${appname}_basic"; nodes.client = { imports = [ (clientLogin appname) ]; }; nodes.server = { imports = [ (basic appname) ]; }; testScript = (commonTestScript appname cfgPathFn).access; }; backupTest = appname: cfgPathFn: shb.test.runNixOSTest { name = "arr_${appname}_backup"; nodes.server = { config, ... }: { imports = [ (basic appname) (shb.test.backup config.shb.arr.${appname}.backup) ]; }; nodes.client = { }; testScript = (commonTestScript appname cfgPathFn).backup; }; https = appname: { config, ... }: { shb.arr.${appname} = { ssl = config.shb.certs.certs.selfsigned.n; }; }; httpsTest = appname: cfgPathFn: shb.test.runNixOSTest { name = "arr_${appname}_https"; nodes.server = { config, pkgs, ... }: { imports = [ (basic appname) shb.test.certs (https appname) ]; }; nodes.client = { }; testScript = (commonTestScript appname cfgPathFn).access; }; sso = appname: { config, ... }: { shb.arr.${appname} = { authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; ssoTest = appname: cfgPathFn: shb.test.runNixOSTest { name = "arr_${appname}_sso"; nodes.server = { config, pkgs, ... }: { imports = [ (basic appname) shb.test.certs (https appname) shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) (sso appname) ]; }; nodes.client = { }; testScript = (commonTestScript appname cfgPathFn).access.override { redirectSSO = true; }; }; radarrCfgFn = cfg: "${cfg.dataDir}/config.xml"; sonarrCfgFn = cfg: "${cfg.dataDir}/config.xml"; bazarrCfgFn = cfg: null; readarrCfgFn = cfg: "${cfg.dataDir}/config.xml"; lidarrCfgFn = cfg: "${cfg.dataDir}/config.xml"; jackettCfgFn = cfg: "${cfg.dataDir}/ServerConfig.json"; in { radarr_basic = basicTest "radarr" radarrCfgFn; radarr_backup = backupTest "radarr" radarrCfgFn; radarr_https = httpsTest "radarr" radarrCfgFn; radarr_sso = ssoTest "radarr" radarrCfgFn; sonarr_basic = basicTest "sonarr" sonarrCfgFn; sonarr_backup = backupTest "sonarr" sonarrCfgFn; sonarr_https = httpsTest "sonarr" sonarrCfgFn; sonarr_sso = ssoTest "sonarr" sonarrCfgFn; bazarr_basic = basicTest "bazarr" bazarrCfgFn; bazarr_backup = backupTest "bazarr" bazarrCfgFn; bazarr_https = httpsTest "bazarr" bazarrCfgFn; bazarr_sso = ssoTest "bazarr" bazarrCfgFn; readarr_basic = basicTest "readarr" readarrCfgFn; readarr_backup = backupTest "readarr" readarrCfgFn; readarr_https = httpsTest "readarr" readarrCfgFn; readarr_sso = ssoTest "readarr" readarrCfgFn; lidarr_basic = basicTest "lidarr" lidarrCfgFn; lidarr_backup = backupTest "lidarr" lidarrCfgFn; lidarr_https = httpsTest "lidarr" lidarrCfgFn; lidarr_sso = ssoTest "lidarr" lidarrCfgFn; jackett_basic = basicTest "jackett" jackettCfgFn; jackett_backup = backupTest "jackett" jackettCfgFn; jackett_https = httpsTest "jackett" jackettCfgFn; jackett_sso = ssoTest "jackett" jackettCfgFn; } ================================================ FILE: test/services/audiobookshelf.nix ================================================ { shb, ... }: let commonTestScript = shb.test.accessScript { hasSSL = { node, ... }: !(isNull node.config.shb.audiobookshelf.ssl); waitForServices = { ... }: [ "audiobookshelf.service" "nginx.service" ]; waitForPorts = { node, ... }: [ node.config.shb.audiobookshelf.webPort ]; # TODO: Test login # extraScript = { ... }: '' # ''; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/audiobookshelf.nix ]; test = { subdomain = "a"; }; shb.audiobookshelf = { enable = true; inherit (config.test) subdomain domain; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "a"; }; test.login = { startUrl = "http://${config.test.fqdn}"; usernameFieldLabelRegex = "[Uu]sername"; passwordFieldLabelRegex = "[Pp]assword"; loginButtonNameRegex = "[Ll]og [Ii]n"; testLoginWith = [ # Failure is after so we're not throttled too much. { username = "root"; password = "rootpw"; nextPageExpect = [ "expect(page.get_by_text('Wrong username or password')).to_be_visible()" ]; } # { username = adminUser; password = adminPass; nextPageExpect = [ # "expect(page.get_by_text('Wrong username or password')).not_to_be_visible()" # "expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()" # "expect(page).to_have_title(re.compile('Dashboard'))" # ]; } ]; }; }; https = { config, ... }: { shb.audiobookshelf = { ssl = config.shb.certs.certs.selfsigned.n; }; }; sso = { config, ... }: { shb.audiobookshelf = { sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; sharedSecret.result = config.shb.hardcodedsecret.audiobookshelfSSOPassword.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia.result; }; }; shb.hardcodedsecret.audiobookshelfSSOPassword = { request = config.shb.audiobookshelf.sso.sharedSecret.request; settings.content = "ssoPassword"; }; shb.hardcodedsecret.audiobookshelfSSOPasswordAuthelia = { request = config.shb.audiobookshelf.sso.sharedSecretForAuthelia.request; settings.content = "ssoPassword"; }; }; in { basic = shb.test.runNixOSTest { name = "audiobookshelf-basic"; nodes.client = { imports = [ # TODO: enable this when declarative user management is possible. # clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript; }; https = shb.test.runNixOSTest { name = "audiobookshelf-https"; nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { }; testScript = commonTestScript; }; sso = shb.test.runNixOSTest { name = "audiobookshelf-sso"; nodes.server = { config, ... }: { imports = [ basic shb.test.certs https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; nodes.client = { }; testScript = commonTestScript; }; } ================================================ FILE: test/services/deluge.nix ================================================ { pkgs, lib, shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.deluge.ssl); waitForServices = { ... }: [ "nginx.service" "deluged.service" "delugeweb.service" ]; waitForPorts = { node, ... }: [ node.config.shb.deluge.daemonPort node.config.shb.deluge.webPort ]; extraScript = { node, proto_fqdn, ... }: '' print(${node.name}.succeed('journalctl -n100 -u deluged')) print(${node.name}.succeed('systemctl status deluged')) print(${node.name}.succeed('systemctl status delugeweb')) with subtest("web connect"): print(server.succeed("cat ${node.config.services.deluge.dataDir}/.config/deluge/auth")) response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """ -H "Content-Type: application/json" -H "Accept: application/json" """), data = unline_with(" ", """ {"method": "auth.login", "params": ["deluge"], "id": 1} """)) print(response) if response['error']: raise Exception(f"error is {response['error']}") if not response['result']: raise Exception(f"response is {response}") response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """ -H "Content-Type: application/json" -H "Accept: application/json" """), data = unline_with(" ", """ {"method": "web.get_hosts", "params": [], "id": 1} """)) print(response) if response['error']: raise Exception(f"error is {response['error']}") hostID = response['result'][0][0] response = curl(client, "", "${proto_fqdn}/json", extra = unline_with(" ", """ -H "Content-Type: application/json" -H "Accept: application/json" """), data = unline_with(" ", f""" {{"method": "web.connect", "params": ["{hostID}"], "id": 1}} """)) print(response) if response['error']: raise Exception(f"result had an error {response['error']}") ''; }; prometheusTestScript = { nodes, ... }: '' server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.deluge.port}) with subtest("prometheus"): response = server.succeed( "curl -sSf " + " http://localhost:${toString nodes.server.services.prometheus.exporters.deluge.port}/metrics" ) print(response) ''; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/deluge.nix ]; test = { subdomain = "d"; }; shb.deluge = { enable = true; inherit (config.test) domain subdomain; settings = { downloadLocation = "/var/lib/deluge"; }; extraUsers = { user.password.source = pkgs.writeText "userpw" "userpw"; }; localclientPassword.result = config.shb.hardcodedsecret."localclientpassword".result; }; shb.hardcodedsecret."localclientpassword" = { request = config.shb.deluge.localclientPassword.request; settings.content = "localpw"; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "d"; }; test.login = { passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "Login"; testLoginWith = [ { password = "deluge"; nextPageExpect = [ "expect(page.get_by_role('button', name='Login')).not_to_be_visible()" "expect(page.get_by_text('Login Failed')).not_to_be_visible()" ]; } { password = "other"; nextPageExpect = [ "expect(page.get_by_role('button', name='Login')).to_be_visible()" "expect(page.get_by_text('Login Failed')).to_be_visible()" ]; } ]; }; }; prometheus = { config, ... }: { shb.deluge = { prometheusScraperPassword.result = config.shb.hardcodedsecret."scraper".result; }; shb.hardcodedsecret."scraper" = { request = config.shb.deluge.prometheusScraperPassword.request; settings.content = "scraperpw"; }; }; https = { config, ... }: { shb.deluge = { ssl = config.shb.certs.certs.selfsigned.n; }; }; sso = { config, ... }: { shb.deluge = { authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; in { basic = shb.test.runNixOSTest { name = "deluge_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "deluge_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.deluge.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "deluge_https"; nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "deluge_sso"; nodes.server = { config, ... }: { imports = [ basic shb.test.certs https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; nodes.client = { }; testScript = commonTestScript.access.override { redirectSSO = true; }; }; prometheus = shb.test.runNixOSTest { name = "deluge_https"; nodes.server = { imports = [ basic shb.test.certs https prometheus ]; }; nodes.client = { }; # The inputs attrset must be named out explicitly testScript = inputs@{ nodes, ... }: (commonTestScript.access inputs) + (prometheusTestScript inputs); }; } ================================================ FILE: test/services/firefly-iii.nix ================================================ { pkgs, shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.firefly-iii.ssl); waitForServices = { ... }: [ "phpfpm-firefly-iii.service" "phpfpm-firefly-iii-data-importer.service" "nginx.service" ]; waitForPorts = { node, ... }: [ # node.config.shb.firefly-iii.port ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/firefly-iii.nix ]; test = { subdomain = "f"; }; shb.firefly-iii = { enable = true; debug = true; inherit (config.test) subdomain domain; siteOwnerEmail = "mail@example.com"; appKey.result = config.shb.hardcodedsecret.appKey.result; dbPassword.result = config.shb.hardcodedsecret.dbPassword.result; importer.firefly-iii-accessToken.result = config.shb.hardcodedsecret.accessToken.result; }; shb.hardcodedsecret.appKey = { request = config.shb.firefly-iii.appKey.request; # Firefly-iir requires this to be exactly 32 characters. settings.content = pkgs.lib.strings.replicate 32 "Z"; }; shb.hardcodedsecret.dbPassword = { request = config.shb.firefly-iii.dbPassword.request; settings.content = pkgs.lib.strings.replicate 64 "Y"; }; shb.hardcodedsecret.accessToken = { request = config.shb.firefly-iii.importer.firefly-iii-accessToken.request; settings.content = pkgs.lib.strings.replicate 64 "X"; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "f"; }; test.login = { startUrl = "http://${config.test.fqdn}"; # There is no login without SSO integration. testLoginWith = [ { username = null; password = null; nextPageExpect = [ "expect(page.get_by_text('Register a new account')).to_be_visible()" ]; } ]; }; }; clientLoginDataImporter = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "f-importer"; }; test.login = { startUrl = "http://${config.test.fqdn}"; # There is no login without SSO integration. testLoginWith = [ { username = null; password = null; nextPageExpect = [ # The error to connect is expected since the access token must be created manually in Firefly-iii. "expect(page.get_by_text('The importer could not connect')).to_be_visible()" ]; } ]; }; }; https = { config, ... }: { shb.firefly-iii = { ssl = config.shb.certs.certs.selfsigned.n; }; }; ldap = { config, ... }: { shb.firefly-iii = { ldap = { userGroup = "user_group"; adminGroup = "admin_group"; }; }; }; clientLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "f"; }; test.login = { startUrl = "https://${config.test.fqdn}"; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('Dashboard')).to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('Dashboard')).to_be_visible(timeout=10000)" ]; } { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "expect(page).to_have_url(re.compile('.*/authenticated'))" ]; } ]; }; }; clientLoginSsoDataImporter = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "f-importer"; }; test.login = { startUrl = "https://${config.test.fqdn}"; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" # Only admins have access "expect(page.get_by_text('Authenticated')).to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" # The error to connect is expected since the access token must be created manually in Firefly-iii. "expect(page.get_by_text('The importer could not connect')).to_be_visible(timeout=10000)" ]; } { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "expect(page).to_have_url(re.compile('.*/authenticated'))" ]; } ]; }; }; sso = { config, ... }: { shb.firefly-iii = { sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; }; in { basic = shb.test.runNixOSTest { name = "firefly-iii_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; data-importer_basic = shb.test.runNixOSTest { name = "firefly-iii-data-importer_basic"; nodes.client = { imports = [ clientLoginDataImporter ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "firefly-iii_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.firefly-iii.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "firefly-iii_https"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic shb.test.certs https ]; }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "firefly-iii_sso"; nodes.client = { imports = [ clientLoginSso ]; }; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; testScript = commonTestScript.access.override { redirectSSO = true; }; }; data-importer_sso = shb.test.runNixOSTest { name = "firefly-iii-data-importer_sso"; nodes.client = { imports = [ clientLoginSsoDataImporter ]; }; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; testScript = commonTestScript.access; }; } ================================================ FILE: test/services/forgejo.nix ================================================ { shb, ... }: let adminPassword = "AdminPassword"; commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.forgejo.ssl); waitForServices = { ... }: [ "forgejo.service" "nginx.service" ]; waitForUnixSocket = { node, ... }: [ node.config.services.forgejo.settings.server.HTTP_ADDR ]; extraScript = { node, ... }: '' server.wait_for_unit("gitea-runner-local.service", timeout=10) server.succeed("journalctl -o cat -u gitea-runner-local.service | grep -q 'Runner registered successfully'") ''; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/forgejo.nix ]; test = { subdomain = "f"; }; shb.forgejo = { enable = true; inherit (config.test) subdomain domain; users = { "theadmin" = { isAdmin = true; email = "theadmin@example.com"; password.result = config.shb.hardcodedsecret.forgejoAdminPassword.result; }; "theuser" = { email = "theuser@example.com"; password.result = config.shb.hardcodedsecret.forgejoUserPassword.result; }; }; databasePassword.result = config.shb.hardcodedsecret.forgejoDatabasePassword.result; }; # Needed for gitea-runner-local to be able to ping forgejo. networking.hosts = { "127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ]; }; shb.hardcodedsecret.forgejoAdminPassword = { request = config.shb.forgejo.users."theadmin".password.request; settings.content = adminPassword; }; shb.hardcodedsecret.forgejoUserPassword = { request = config.shb.forgejo.users."theuser".password.request; settings.content = "userPassword"; }; shb.hardcodedsecret.forgejoDatabasePassword = { request = config.shb.forgejo.databasePassword.request; settings.content = "databasePassword"; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "f"; }; test.login = { startUrl = "http://${config.test.fqdn}/user/login"; usernameFieldLabelRegex = "Username or email address"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "theadmin"; password = adminPassword + "oops"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()" ]; } { username = "theadmin"; password = adminPassword; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page).to_have_title(re.compile('Dashboard'))" ]; } { username = "theuser"; password = "userPasswordOops"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()" ]; } { username = "theuser"; password = "userPassword"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page).to_have_title(re.compile('Dashboard'))" ]; } ]; }; }; https = { config, ... }: { shb.forgejo = { ssl = config.shb.certs.certs.selfsigned.n; }; }; ldap = { config, ... }: { shb.forgejo = { ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminPassword.result = config.shb.hardcodedsecret.forgejoLdapUserPassword.result; waitForSystemdServices = [ "lldap.service" ]; userGroup = "user_group"; adminGroup = "admin_group"; }; }; shb.hardcodedsecret.forgejoLdapUserPassword = { request = config.shb.forgejo.ldap.adminPassword.request; settings.content = "ldapUserPassword"; }; }; sso = { config, ... }: { shb.forgejo = { sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; sharedSecret.result = config.shb.hardcodedsecret.forgejoSSOPassword.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.forgejoSSOPasswordAuthelia.result; }; }; shb.hardcodedsecret.forgejoSSOPassword = { request = config.shb.forgejo.sso.sharedSecret.request; settings.content = "ssoPassword"; }; shb.hardcodedsecret.forgejoSSOPasswordAuthelia = { request = config.shb.forgejo.sso.sharedSecretForAuthelia.request; settings.content = "ssoPassword"; }; }; in { basic = shb.test.runNixOSTest { name = "forgejo_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "forgejo_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.forgejo.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "forgejo_https"; nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { }; testScript = commonTestScript.access; }; ldap = shb.test.runNixOSTest { name = "forgejo_ldap"; nodes.server = { imports = [ basic shb.test.ldap ldap ]; }; nodes.client = { imports = [ ( { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "f"; }; test.login = { startUrl = "http://${config.test.fqdn}/user/login"; usernameFieldLabelRegex = "Username or email address"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page).to_have_title(re.compile('Dashboard'))" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page).to_have_title(re.compile('Dashboard'))" ]; } { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "expect(page.get_by_text('Username or password is incorrect.')).to_be_visible()" ]; } ]; }; } ) ]; }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "forgejo_sso"; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https ldap shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; nodes.client = { }; testScript = commonTestScript.access; }; } ================================================ FILE: test/services/grocy.nix ================================================ { shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.grocy.ssl); waitForServices = { ... }: [ "phpfpm-grocy.service" "nginx.service" ]; waitForUnixSocket = { node, ... }: [ node.config.services.phpfpm.pools.grocy.socket ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/grocy.nix ]; test = { subdomain = "g"; }; shb.grocy = { enable = true; inherit (config.test) subdomain domain; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "g"; }; test.login = { startUrl = "http://${config.test.fqdn}"; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "OK"; testLoginWith = [ { username = "admin"; password = "admin oops"; nextPageExpect = [ "expect(page.get_by_text('Invalid credentials, please try again')).to_be_visible()" ]; } { username = "admin"; password = "admin"; nextPageExpect = [ "expect(page.get_by_text('Invalid credentials, please try again')).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('OK'))).not_to_be_visible()" "expect(page).to_have_title(re.compile('Grocy'))" ]; } ]; }; }; https = { config, ... }: { shb.grocy = { ssl = config.shb.certs.certs.selfsigned.n; }; }; in { basic = shb.test.runNixOSTest { name = "grocy_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; https = shb.test.runNixOSTest { name = "grocy_https"; nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { }; testScript = commonTestScript.access; }; } ================================================ FILE: test/services/hledger.nix ================================================ { shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.hledger.ssl); waitForServices = { ... }: [ "hledger-web.service" "nginx.service" ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/hledger.nix ]; test = { subdomain = "h"; }; shb.hledger = { enable = true; inherit (config.test) subdomain domain; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "h"; }; test.login = { startUrl = "http://${config.test.fqdn}"; testLoginWith = [ { nextPageExpect = [ "expect(page).to_have_title('journal - hledger-web')" ]; } ]; }; }; https = { config, ... }: { shb.hledger = { ssl = config.shb.certs.certs.selfsigned.n; }; }; sso = { config, ... }: { shb.hledger = { authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; in { basic = shb.test.runNixOSTest { name = "hledger_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "hledger_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.hledger.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "hledger_https"; nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "hledger_sso"; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; nodes.client = { }; testScript = commonTestScript.access.override { redirectSSO = true; }; }; } ================================================ FILE: test/services/home-assistant.nix ================================================ { pkgs, shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.home-assistant.ssl); waitForServices = { ... }: [ "home-assistant.service" "nginx.service" ]; waitForPorts = { node, ... }: [ 8123 ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/home-assistant.nix ]; test = { subdomain = "ha"; }; shb.home-assistant = { enable = true; inherit (config.test) subdomain domain; config = { name = "Tiserbox"; country = "CH"; latitude = "01.0000000000"; longitude.source = pkgs.writeText "longitude" "01.0000000000"; time_zone = "Europe/Zurich"; unit_system = "metric"; }; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "ha"; }; test.login = { startUrl = "http://${config.test.fqdn}"; testLoginWith = [ { nextPageExpect = [ "page.get_by_role('button', name=re.compile('Create my smart home')).click()" "expect(page.get_by_text('Create user')).to_be_visible()" "page.get_by_label(re.compile('Name')).fill('Admin')" "page.get_by_label(re.compile('Username')).fill('admin')" "page.get_by_label(re.compile('Password')).fill('adminpassword')" "page.get_by_label(re.compile('Confirm password')).fill('adminpassword')" "page.get_by_role('button', name=re.compile('Create account')).click()" "expect(page.get_by_text('All set!')).to_be_visible()" "page.get_by_role('button', name=re.compile('Finish')).click()" "expect(page).to_have_title(re.compile('Overview'), timeout=15000)" ]; } ]; }; }; https = { config, ... }: { shb.home-assistant = { ssl = config.shb.certs.certs.selfsigned.n; }; }; ldap = { config, ... }: { shb.home-assistant = { ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.webUIListenPort; userGroup = "homeassistant_user"; }; }; }; # Not yet supported # # sso = { config, ... }: { # shb.home-assistant = { # sso = { # }; # }; # }; voice = { config, ... }: { # For now, verifying the packages can build is good enough. environment.systemPackages = [ config.services.wyoming.piper.package config.services.wyoming.openwakeword.package config.services.wyoming.faster-whisper.package ]; # TODO: enable this back. The issue id the services cannot talk to the internet # to download the models so they fail to start.. # shb.home-assistant.voice.text-to-speech = { # "fr" = { # enable = true; # voice = "fr-siwis-medium"; # uri = "tcp://0.0.0.0:10200"; # speaker = 0; # }; # "en" = { # enable = true; # voice = "en_GB-alba-medium"; # uri = "tcp://0.0.0.0:10201"; # speaker = 0; # }; # }; # shb.home-assistant.voice.speech-to-text = { # "tiny-fr" = { # enable = true; # model = "base-int8"; # language = "fr"; # uri = "tcp://0.0.0.0:10300"; # device = "cpu"; # }; # "tiny-en" = { # enable = true; # model = "base-int8"; # language = "en"; # uri = "tcp://0.0.0.0:10301"; # device = "cpu"; # }; # }; # shb.home-assistant.voice.wakeword = { # enable = true; # uri = "tcp://127.0.0.1:10400"; # preloadModels = [ # "alexa" # "hey_jarvis" # "hey_mycroft" # "hey_rhasspy" # "ok_nabu" # ]; # }; }; in { basic = shb.test.runNixOSTest { name = "homeassistant_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "homeassistant_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.home-assistant.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "homeassistant_https"; nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { }; testScript = commonTestScript.access; }; ldap = shb.test.runNixOSTest { name = "homeassistant_ldap"; nodes.server = { imports = [ basic shb.test.ldap ldap ]; }; nodes.client = { }; testScript = commonTestScript.access; }; # Not yet supported # # sso = shb.test.runNixOSTest { # name = "vaultwarden_sso"; # # nodes.server = lib.mkMerge [ # basic # (shb.certs domain) # https # ldap # (shb.ldap domain pkgs') # (shb.test.sso domain pkgs' config.shb.certs.certs.selfsigned.n) # sso # ]; # # nodes.client = {}; # # testScript = commonTestScript.access; # }; voice = shb.test.runNixOSTest { name = "homeassistant_voice"; nodes.server = { imports = [ basic voice ]; }; nodes.client = { }; testScript = commonTestScript.access; }; } ================================================ FILE: test/services/homepage.nix ================================================ { shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.homepage.ssl); waitForServices = { ... }: [ "homepage-dashboard.service" "nginx.service" ]; waitForPorts = { node, ... }: [ node.config.services.homepage-dashboard.listenPort ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/homepage.nix ]; test = { subdomain = "h"; }; shb.homepage = { enable = true; inherit (config.test) subdomain domain; servicesGroups.MyHomeGroup.services.TestService.dashboard = { }; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "h"; }; test.login = { startUrl = "http://${config.test.fqdn}"; # There is no login without SSO integration. testLoginWith = [ { username = null; password = null; nextPageExpect = [ "expect(page.get_by_text('TestService')).to_be_visible()" ]; } ]; }; }; https = { config, ... }: { shb.homepage = { ssl = config.shb.certs.certs.selfsigned.n; }; }; ldap = { config, ... }: { shb.homepage = { ldap = { userGroup = "user_group"; }; }; }; clientLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "h"; }; test.login = { startUrl = "https://${config.test.fqdn}"; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('TestService')).to_be_visible(timeout=10000)" ]; } # Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep. { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "expect(page).to_have_url(re.compile('.*/authenticated'))" ]; } ]; }; }; sso = { config, ... }: { shb.homepage = { sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; }; in { basic = shb.test.runNixOSTest { name = "homepage_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; https = shb.test.runNixOSTest { name = "homepage_https"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic shb.test.certs https ]; }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "homepage_sso"; nodes.client = { imports = [ clientLoginSso ]; }; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; testScript = commonTestScript.access.override { redirectSSO = true; }; }; } ================================================ FILE: test/services/immich.nix ================================================ { pkgs, lib, shb, }: let subdomain = "i"; domain = "example.com"; commonTestScript = shb.test.accessScript { hasSSL = { node, ... }: !(isNull node.config.shb.immich.ssl); waitForServices = { ... }: [ "immich-server.service" "postgresql.service" "nginx.service" ]; waitForPorts = { ... }: [ 2283 80 ]; waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ]; }; base = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/immich.nix ]; virtualisation.memorySize = 4096; virtualisation.cores = 2; test = { inherit subdomain domain; }; shb.immich = { enable = true; inherit subdomain domain; debug = true; }; # Required for tests environment.systemPackages = [ pkgs.curl ]; }; basic = { config, ... }: { imports = [ base ]; test.hasSSL = false; }; https = { config, ... }: { imports = [ base shb.test.certs ]; test.hasSSL = true; shb.immich.ssl = config.shb.certs.certs.selfsigned.n; }; backup = { config, ... }: { imports = [ https (shb.test.backup config.shb.immich.backup) ]; }; sso = { config, ... }: { imports = [ https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) ]; shb.immich.sso = { enable = true; provider = "Authelia"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; clientID = "immich"; autoLaunch = true; sharedSecret.result = config.shb.hardcodedsecret.immichSSOSecret.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.immichSSOSecretAuthelia.result; }; shb.hardcodedsecret.immichSSOSecret = { request = config.shb.immich.sso.sharedSecret.request; settings.content = "immichSSOSecret"; }; shb.hardcodedsecret.immichSSOSecretAuthelia = { request = config.shb.immich.sso.sharedSecretForAuthelia.request; settings.content = "immichSSOSecret"; }; # Configure LDAP groups for group-based access control shb.lldap.ensureGroups.immich_user = { }; shb.lldap.ensureUsers.immich_test_user = { email = "immich_user@example.com"; groups = [ "immich_user" ]; password.result = config.shb.hardcodedsecret.ldapImmichUserPassword.result; }; shb.lldap.ensureUsers.regular_test_user = { email = "regular_user@example.com"; groups = [ ]; password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result; }; shb.hardcodedsecret.ldapImmichUserPassword = { request = config.shb.lldap.ensureUsers.immich_test_user.password.request; settings.content = "immich_user_password"; }; shb.hardcodedsecret.ldapRegularUserPassword = { request = config.shb.lldap.ensureUsers.regular_test_user.password.request; settings.content = "regular_user_password"; }; }; in { basic = shb.test.runNixOSTest { name = "immich-basic"; nodes.server = basic; nodes.client = { }; testScript = commonTestScript; }; https = shb.test.runNixOSTest { name = "immich-https"; nodes.server = https; nodes.client = { }; testScript = commonTestScript; }; backup = shb.test.runNixOSTest { name = "immich-backup"; nodes.server = backup; nodes.client = { }; testScript = (shb.test.mkScripts { hasSSL = args: !(isNull args.node.config.shb.immich.ssl); waitForServices = args: [ "immich-server.service" "postgresql.service" "nginx.service" ]; waitForPorts = args: [ 2283 80 ]; waitForUrls = args: [ "${args.proto_fqdn}" ]; }).backup; }; } ================================================ FILE: test/services/jellyfin.nix ================================================ { pkgs, shb, ... }: let port = 9096; adminUser = "jellyfin2"; adminPassword = "admin"; commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.jellyfin.ssl); waitForServices = { ... }: [ "jellyfin.service" "nginx.service" ]; waitForPorts = { node, ... }: [ port ]; waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}/System/Info/Public" { url = "${proto_fqdn}/Users/AuthenticateByName"; status = 401; } ]; extraScript = { node, ... }: '' server.wait_until_succeeds("journalctl --since -1m --unit jellyfin --grep 'Startup complete'") headers = unline_with(" ", """ -H 'Content-Type: application/json' -H 'Authorization: MediaBrowser Client="Android TV", Device="Nvidia Shield", DeviceId="ZQ9YQHHrUzk24vV", Version="0.15.3"' """) import time with subtest("api login success"): ok = False for i in range(1, 5): response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName", data="""{"Username": "${adminUser}", "Pw": "${adminPassword}"}""", extra=headers) if response['code'] == 200: ok = True break time.sleep(5) if not ok: raise Exception(f"Expected success, got: {response['code']}") with subtest("api login failure"): response = curl(client, """{"code":%{response_code}}""", "${node.config.test.proto_fqdn}/Users/AuthenticateByName", data="""{"Username": "${adminUser}", "Pw": "badpassword"}""", extra=headers) if response['code'] != 401: raise Exception(f"Expected failure, got: {response['code']}") ''; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/jellyfin.nix ]; # Jellyfin checks for minimum 2Gib on startup. virtualisation.diskSize = 4096; virtualisation.memorySize = 4096; test = { subdomain = "j"; }; shb.jellyfin = { enable = true; inherit (config.test) subdomain domain; inherit port; admin = { username = adminUser; password.result = config.shb.hardcodedsecret.jellyfinAdminPassword.result; }; debug = true; }; shb.hardcodedsecret.jellyfinAdminPassword = { request = config.shb.jellyfin.admin.password.request; settings.content = adminPassword; }; environment.systemPackages = [ pkgs.sqlite ]; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "j"; }; test.login = { browser = "firefox"; startUrl = "${config.test.proto}://${config.test.fqdn}"; usernameFieldLabelRegex = "[Uu]ser"; loginButtonNameRegex = "Sign In"; testLoginWith = [ { username = adminUser; password = "badpassword"; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)" ]; } { username = adminUser; password = adminPassword; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)" ]; } ]; }; }; https = { config, ... }: { shb.jellyfin = { ssl = config.shb.certs.certs.selfsigned.n; }; test = { hasSSL = true; }; }; ldap = { config, lib, ... }: { shb.jellyfin = { ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; userGroup = "user_group"; adminGroup = "admin_group"; adminPassword.result = config.shb.hardcodedsecret.jellyfinLdapUserPassword.result; }; }; # There's something weird happending here # where this plugin disappears after a jellyfin restart. # I don't know why this is the case. # I tried using a real plugin here instead of a mock or just creating a meta.json file. # But this didn't help. shb.jellyfin.plugins = lib.mkBefore [ (shb.mkJellyfinPlugin (rec { pname = "jellyfin-plugin-ldapauth"; version = "19"; url = "https://github.com/jellyfin/${pname}/releases/download/v${version}/ldap-authentication_${version}.0.0.0.zip"; hash = "sha256-NunkpdYjsxYT6a4RaDXLkgRn4scRw8GaWvyHGs9IdWo="; })) ]; shb.hardcodedsecret.jellyfinLdapUserPassword = { request = config.shb.jellyfin.ldap.adminPassword.request; settings.content = "ldapUserPassword"; }; }; clientLoginLdap = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "j"; }; test.login = { startUrl = "${config.test.proto}://${config.test.fqdn}"; usernameFieldLabelRegex = "[Uu]ser"; loginButtonNameRegex = "Sign In"; testLoginWith = [ { username = adminUser; password = "badpassword"; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)" ]; } { username = adminUser; password = adminPassword; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" # For a reason I can't explain, redirection needs to happen manually. "page.goto('${config.test.proto}://${config.test.fqdn}/web/')" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)" ]; } { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" # For a reason I can't explain, redirection needs to happen manually. "page.goto('${config.test.proto}://${config.test.fqdn}/web/')" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).to_be_visible(timeout=10000)" ]; } ]; }; }; sso = { config, ... }: { shb.jellyfin = { ldap = { userGroup = "user_group"; adminGroup = "admin_group"; }; sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; sharedSecret.result = config.shb.hardcodedsecret.jellyfinSSOPassword.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.jellyfinSSOPasswordAuthelia.result; }; }; shb.hardcodedsecret.jellyfinSSOPassword = { request = config.shb.jellyfin.sso.sharedSecret.request; settings.content = "ssoPassword"; }; shb.hardcodedsecret.jellyfinSSOPasswordAuthelia = { request = config.shb.jellyfin.sso.sharedSecretForAuthelia.request; settings.content = "ssoPassword"; }; }; clientLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "j"; }; test.login = { startUrl = "${config.test.proto}://${config.test.fqdn}"; beforeHook = '' page.locator('text=Sign in with Authelia').click() ''; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[Ss]ign [Ii]n"; loginSpawnsNewPage = true; testLoginWith = [ { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "page.get_by_text(re.compile('[Aa]ccept')).click()" # For a reason I can't explain, redirection needs to happen manually. "page.goto('${config.test.proto}://${config.test.fqdn}/web/')" # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)" ]; } { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ # For a reason I can't explain, redirection needs to happen manually. # So for failing auth, we check we're back on the login page. "page.goto('${config.test.proto}://${config.test.fqdn}/web/')" # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ "page.get_by_text(re.compile('[Aa]ccept')).click()" # For a reason I can't explain, redirection needs to happen manually. "page.goto('${config.test.proto}://${config.test.fqdn}/web/')" # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_text(re.compile('[Ii]nvalid'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Uu]ser'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).not_to_be_visible(timeout=10000)" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ # For a reason I can't explain, redirection needs to happen manually. "page.goto('${config.test.proto}://${config.test.fqdn}/web/')" # "expect(page).to_have_title(re.compile('Jellyfin'))" "expect(page.get_by_label(re.compile('^[Uu]ser'))).to_be_visible(timeout=10000)" "expect(page.get_by_label(re.compile('^[Pp]assword$'))).to_be_visible(timeout=10000)" ]; } ]; }; }; jellyfinTest = name: { nodes, testScript }: shb.test.runNixOSTest { name = "jellyfin_${name}"; interactive.sshBackdoor.enable = true; interactive.nodes.server = { environment.systemPackages = [ pkgs.sqlite ]; }; inherit nodes; inherit testScript; }; in { basic = jellyfinTest "basic" { nodes.server = { imports = [ basic ]; }; nodes.client = { imports = [ clientLogin ]; }; testScript = commonTestScript.access; }; backup = jellyfinTest "backup" { nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.jellyfin.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = jellyfinTest "https" { nodes.server = { imports = [ basic shb.test.certs https ]; }; nodes.client = { config, lib, ... }: { imports = [ clientLogin ]; }; testScript = commonTestScript.access; }; ldap = jellyfinTest "ldap" { nodes.server = { imports = [ basic shb.test.certs https shb.test.ldap ldap ]; }; nodes.client = { imports = [ clientLoginLdap ]; }; testScript = commonTestScript.access.override { extraScript = { node, ... }: # I have no idea why the LDAP Authentication_19.0.0.0 plugin disappears. '' r = server.execute('cat "${node.config.services.jellyfin.dataDir}/plugins/LDAP Authentication_19.0.0.0/meta.json"') if r[0] != 0: print("meta.json for plugin LDAP Authentication_19.0.0.0 not found") else: c = json.loads(r[1]) if "status" in c and c["status"] != "Disabled": raise Exception(f'meta.json status: expected Disabled, got: {c["status"]}') ''; }; }; sso = jellyfinTest "sso" { nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; nodes.client = { imports = [ clientLoginSso ]; }; testScript = commonTestScript.access; }; } ================================================ FILE: test/services/karakeep.nix ================================================ { shb, ... }: let nextauthSecret = "nextauthSecret"; oidcSecret = "oidcSecret"; commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.karakeep.ssl); waitForServices = { ... }: [ "karakeep-init.service" "karakeep-browser.service" "karakeep-web.service" "karakeep-workers.service" "nginx.service" ]; waitForPorts = { node, ... }: [ node.config.shb.karakeep.port ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/karakeep.nix ]; test = { subdomain = "k"; }; shb.karakeep = { enable = true; inherit (config.test) subdomain domain; nextauthSecret.result = config.shb.hardcodedsecret.nextauthSecret.result; meilisearchMasterKey.result = config.shb.hardcodedsecret.meilisearchMasterKey.result; }; shb.hardcodedsecret.nextauthSecret = { request = config.shb.karakeep.nextauthSecret.request; settings.content = nextauthSecret; }; shb.hardcodedsecret.meilisearchMasterKey = { request = config.shb.karakeep.meilisearchMasterKey.request; settings.content = "meilisearch-master-key"; }; networking.hosts = { "127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ]; }; }; https = { config, ... }: { shb.karakeep = { ssl = config.shb.certs.certs.selfsigned.n; }; }; ldap = { config, ... }: { shb.karakeep = { ldap = { userGroup = "user_group"; }; }; }; clientLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "k"; }; test.login = { startUrl = "https://${config.test.fqdn}"; beforeHook = '' page.get_by_role("button", name="single sign-on").click() ''; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible(timeout=10000)" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('new item')).to_be_visible()" ]; } # Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep. { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible(timeout=10000)" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ # "page.get_by_role('button', name=re.compile('Accept')).click()" # I don't understand why this is not needed. Maybe it keeps somewhere the previous token? "expect(page.get_by_text(re.compile('login failed'))).to_be_visible(timeout=10000)" ]; } ]; }; }; sso = { config, ... }: { shb.karakeep = { sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; clientID = "karakeep"; sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; }; }; shb.hardcodedsecret.oidcSecret = { request = config.shb.karakeep.sso.sharedSecret.request; settings.content = oidcSecret; }; shb.hardcodedsecret.oidcAutheliaSecret = { request = config.shb.karakeep.sso.sharedSecretForAuthelia.request; settings.content = oidcSecret; }; }; in { basic = shb.test.runNixOSTest { name = "karakeep_basic"; nodes.client = { }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "karakeep_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.karakeep.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "karakeep_https"; nodes.client = { }; nodes.server = { imports = [ basic shb.test.certs https ]; }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "karakeep_sso"; nodes.client = { imports = [ clientLoginSso ]; virtualisation.memorySize = 4096; }; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; virtualisation.memorySize = 4096; }; testScript = commonTestScript.access; }; } ================================================ FILE: test/services/nextcloud.nix ================================================ { lib, shb, ... }: let supportedVersion = [ 32 33 ]; adminUser = "root"; adminPass = "rootpw"; oidcSecret = "oidcSecret"; commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.nextcloud.ssl); waitForServices = { ... }: [ "phpfpm-nextcloud.service" "nginx.service" ]; waitForUnixSocket = { node, ... }: [ node.config.services.phpfpm.pools.nextcloud.socket ]; extraScript = { node, fqdn, proto_fqdn, ... }: '' with subtest("fails with incorrect authentication"): client.fail( "curl -f -s --location -X PROPFIND" + """ -H "Depth: 1" """ + """ -u ${adminUser}:other """ + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" + " ${proto_fqdn}/remote.php/dav/files/${adminUser}/" ) client.fail( "curl -f -s --location -X PROPFIND" + """ -H "Depth: 1" """ + """ -u root:rootpw """ + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" + " ${proto_fqdn}/remote.php/dav/files/other/" ) with subtest("fails with incorrect path"): client.fail( "curl -f -s --location -X PROPFIND" + """ -H "Depth: 1" """ + """ -u ${adminUser}:${adminPass} """ + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" + " ${proto_fqdn}/remote.php/dav/files/other/" ) with subtest("can access webdav"): client.succeed( "curl -f -s --location -X PROPFIND" + """ -H "Depth: 1" """ + """ -u ${adminUser}:${adminPass} """ + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" + " ${proto_fqdn}/remote.php/dav/files/${adminUser}/" ) with subtest("can create and retrieve file"): client.fail( "curl -f -s --location -X GET" + """ -H "Depth: 1" """ + """ -u ${adminUser}:${adminPass} """ + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" + """ -T file """ + " ${proto_fqdn}/remote.php/dav/files/${adminUser}/file" ) client.succeed("echo 'hello' > file") client.succeed( "curl -f -s --location -X PUT" + """ -H "Depth: 1" """ + """ -u ${adminUser}:${adminPass} """ + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" + """ -T file """ + " ${proto_fqdn}/remote.php/dav/files/${adminUser}/" ) content = client.succeed( "curl -f -s --location -X GET" + """ -H "Depth: 1" """ + """ -u ${adminUser}:${adminPass} """ + " --connect-to ${fqdn}:443:server:443" + " --connect-to ${fqdn}:80:server:80" + """ -T file """ + " ${proto_fqdn}/remote.php/dav/files/${adminUser}/file" ) if content != "hello\n": raise Exception("Got incorrect content for file, expected 'hello\n' but got:\n{}".format(content)) ''; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/nextcloud-server.nix ]; test = { subdomain = "n"; }; shb.nextcloud = { enable = true; inherit (config.test) subdomain domain; dataDir = "/var/lib/nextcloud"; tracing = null; defaultPhoneRegion = "US"; # This option is only needed because we do not access Nextcloud at the default port in the VM. externalFqdn = "${config.test.fqdn}:8080"; adminUser = adminUser; adminPass.result = config.shb.hardcodedsecret.adminPass.result; debug = false; # Enable this if needed, but beware it is _very_ verbose. }; shb.hardcodedsecret.adminPass = { request = config.shb.nextcloud.adminPass.request; settings.content = adminPass; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "n"; }; test.login = { startUrl = "http://${config.test.fqdn}"; usernameFieldLabelRegex = "Account name"; passwordFieldLabelRegex = "^ *[Pp]assword"; loginButtonNameRegex = "^[Ll]og [Ii]n$"; testLoginWith = [ { username = adminUser; password = adminPass; nextPageExpect = [ "expect(page.get_by_text('Wrong login or password')).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()" "expect(page).to_have_title(re.compile('Dashboard'))" ]; } # Failure is after so we're not throttled too much. { username = adminUser; password = adminPass + "oops"; nextPageExpect = [ "expect(page.get_by_text('Wrong login or password')).to_be_visible()" ]; } ]; }; }; clientLdapLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "n"; }; test.login = { startUrl = "http://${config.test.fqdn}"; usernameFieldLabelRegex = "Account name"; passwordFieldLabelRegex = "^ *[Pp]assword"; loginButtonNameRegex = "^[Ll]og [Ii]n$"; testLoginWith = [ { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "expect(page.get_by_text('Wrong login or password')).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('[Ll]og [Ii]n'))).not_to_be_visible()" "expect(page).to_have_title(re.compile('Dashboard'))" ]; } { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text('Wrong login or password')).to_be_visible()" ]; } ]; }; }; clientSsoLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "n"; }; networking.hosts = { "192.168.1.2" = [ "auth.example.com" ]; }; test.login = { startUrl = "http://${config.test.fqdn}"; # No need since Nextcloud is auto-redirecting to the SSO sign in page. # beforeHook = '' # page.get_by_role("link", name="Sign in with SHB-Authelia").click() # ''; usernameFieldLabelRegex = "Username"; passwordFieldSelector = "get_by_label(\"Password *\")"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page).to_have_title(re.compile('Dashboard'))" "page.goto('https://${config.test.fqdn}/settings/admin')" "expect(page.get_by_text('Access forbidden')).to_be_visible()" ]; } { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text('Incorrect username or password')).to_be_visible()" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page).to_have_title(re.compile('Dashboard'))" "page.goto('https://${config.test.fqdn}/settings/admin')" "expect(page.get_by_text('Access forbidden')).not_to_be_visible()" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ "expect(page.get_by_text('Incorrect username or password')).to_be_visible()" ]; } { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text('Incorrect username or password')).to_be_visible()" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page.get_by_text('not member of the allowed groups')).to_be_visible()" ]; } ]; }; }; https = { config, ... }: { shb.nextcloud = { ssl = config.shb.certs.certs.selfsigned.n; externalFqdn = lib.mkForce null; }; }; ldap = { config, ... }: { shb.nextcloud = { apps.ldap = { enable = true; host = "127.0.0.1"; port = config.shb.lldap.ldapPort; dcdomain = config.shb.lldap.dcdomain; adminName = "admin"; adminPassword.result = config.shb.hardcodedsecret.nextcloudLdapUserPassword.result; userGroup = "user_group"; }; }; shb.hardcodedsecret.nextcloudLdapUserPassword = { request = config.shb.nextcloud.apps.ldap.adminPassword.request; settings = config.shb.hardcodedsecret.ldapUserPassword.settings; }; }; sso = { config, ... }: { shb.nextcloud = { apps.ldap = { userGroup = "user_group"; }; apps.sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; clientID = "nextcloud"; adminGroup = "admin_group"; secret.result = config.shb.hardcodedsecret.oidcSecret.result; secretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; fallbackDefaultAuth = false; }; }; # Needed because OIDC somehow does not like self-signed certificates # which we do use in tests. # See https://github.com/pulsejet/nextcloud-oidc-login/issues/267 services.nextcloud.settings.oidc_login_tls_verify = lib.mkForce false; shb.hardcodedsecret.oidcSecret = { request = config.shb.nextcloud.apps.sso.secret.request; settings.content = oidcSecret; }; shb.hardcodedsecret.oidcAutheliaSecret = { request = config.shb.nextcloud.apps.sso.secretForAuthelia.request; settings.content = oidcSecret; }; }; previewgenerator = { config, ... }: { systemd.tmpfiles.rules = [ "d '/srv/nextcloud' 0750 nextcloud nextcloud - -" ]; shb.nextcloud = { apps.previewgenerator.enable = true; }; }; externalstorage = { systemd.tmpfiles.rules = [ "d '/srv/nextcloud' 0750 nextcloud nextcloud - -" ]; shb.nextcloud = { apps.externalStorage = { enable = true; userLocalMount.directory = "/srv/nextcloud/$user"; userLocalMount.mountName = "home"; }; }; }; memories = { config, ... }: { systemd.tmpfiles.rules = [ "d '/srv/nextcloud' 0750 nextcloud nextcloud - -" ]; shb.nextcloud = { apps.memories.enable = true; apps.memories.vaapi = true; }; }; recognize = { config, ... }: { systemd.tmpfiles.rules = [ "d '/srv/nextcloud' 0750 nextcloud nextcloud - -" ]; shb.nextcloud = { apps.recognize.enable = true; }; }; prometheus = { config, ... }: { shb.nextcloud = { phpFpmPrometheusExporter.enable = true; }; }; prometheusTestScript = { nodes, ... }: '' server.wait_for_open_unix_socket("${nodes.server.services.phpfpm.pools.nextcloud.socket}") server.wait_for_open_port(${toString nodes.server.services.prometheus.exporters.php-fpm.port}) with subtest("prometheus"): response = server.succeed( "curl -sSf " + " http://localhost:${toString nodes.server.services.prometheus.exporters.php-fpm.port}/metrics" ) print(response) ''; basicTest = version: shb.test.runNixOSTest { name = "nextcloud_basic_${toString version}"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic { shb.nextcloud.version = version; } ]; }; testScript = commonTestScript.access; }; cronTest = version: shb.test.runNixOSTest { name = "nextcloud_cron_${toString version}"; nodes.server = { imports = [ basic { shb.nextcloud.version = version; } ]; }; nodes.client = { }; testScript = commonTestScript.access.override { extraScript = { node, fqdn, proto_fqdn, ... }: '' import time def find_in_logs(unit, text): return server.systemctl("status {}".format(unit))[1].find(text) != -1 with subtest("cron job succeeds"): # This call does not block until the service is done. server.succeed("systemctl start nextcloud-cron.service&") # If the service failed, then we're not happy. status = "active" while status == "active": status = server.get_unit_info("nextcloud-cron")["ActiveState"] time.sleep(5) if status != "inactive": raise Exception("Cron job did not finish correctly") if not find_in_logs("nextcloud-cron", "nextcloud-cron.service: Deactivated successfully."): raise Exception("Nextcloud cron job did not finish successfully.") ''; }; }; backupTest = version: shb.test.runNixOSTest { name = "nextcloud_backup_${toString version}"; nodes.server = { config, ... }: { imports = [ basic { shb.nextcloud.version = version; } (shb.test.backup config.shb.nextcloud.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; httpsTest = version: shb.test.runNixOSTest { name = "nextcloud_https_${toString version}"; nodes.server = { imports = [ basic { shb.nextcloud.version = version; } shb.test.certs https ]; }; nodes.client = { }; # TODO: Test login testScript = commonTestScript.access; }; previewGeneratorTest = version: shb.test.runNixOSTest { name = "nextcloud_previewGenerator_${toString version}"; nodes.server = { imports = [ basic { shb.nextcloud.version = version; } shb.test.certs https previewgenerator ]; }; nodes.client = { }; testScript = commonTestScript.access; }; externalStorageTest = version: shb.test.runNixOSTest { name = "nextcloud_externalStorage_${toString version}"; nodes.server = { imports = [ basic { shb.nextcloud.version = version; } shb.test.certs https externalstorage ]; }; nodes.client = { }; testScript = commonTestScript.access; }; # TODO: fix memories app # See https://github.com/ibizaman/selfhostblocks/issues/476 memoriesTest = version: shb.test.runNixOSTest { name = "nextcloud_memories_${toString version}"; nodes.server = { imports = [ basic { shb.nextcloud.version = version; } shb.test.certs https memories ]; }; nodes.client = { }; testScript = commonTestScript.access; }; recognizeTest = version: shb.test.runNixOSTest { name = "nextcloud_recognize_${toString version}"; nodes.server = { imports = [ basic { shb.nextcloud.version = version; } shb.test.certs https recognize ]; }; nodes.client = { }; testScript = commonTestScript.access; }; ldapTest = version: shb.test.runNixOSTest { name = "nextcloud_ldap_${toString version}"; nodes.server = { config, ... }: { imports = [ basic { shb.nextcloud.version = version; } shb.test.certs https shb.test.ldap ldap ]; }; nodes.client = { imports = [ clientLdapLogin ]; }; testScript = commonTestScript.access; }; ssoTest = version: shb.test.runNixOSTest { name = "nextcloud_sso_${toString version}"; nodes.server = { config, ... }: { imports = [ basic { shb.nextcloud.version = version; } shb.test.certs https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ( { config, ... }: { networking.hosts = { "127.0.0.1" = [ config.test.fqdn ]; }; } ) ]; }; nodes.client = { imports = [ clientSsoLogin ( { config, ... }: { networking.hosts = { "192.168.1.2" = [ config.test.fqdn ]; }; } ) ]; }; testScript = commonTestScript.access; }; prometheusTest = version: shb.test.runNixOSTest { name = "nextcloud_prometheus_${toString version}"; nodes.server = { config, ... }: { imports = [ basic { shb.nextcloud.version = version; } prometheus ]; }; nodes.client = { }; testScript = prometheusTestScript; }; versionedTests = v: { "basic_${toString v}" = basicTest v; "cron_${toString v}" = cronTest v; "backup_${toString v}" = backupTest v; "https_${toString v}" = httpsTest v; "previewGenerator_${toString v}" = previewGeneratorTest v; "externalStorage_${toString v}" = externalStorageTest v; "ldap_${toString v}" = ldapTest v; "sso_${toString v}" = ssoTest v; "prometheus_${toString v}" = prometheusTest v; } // lib.optionalAttrs (v == 32) { "memories_${toString v}" = memoriesTest v; "recognize_${toString v}" = recognizeTest v; }; in lib.foldl (all: v: lib.mergeAttrs all (versionedTests v)) { } supportedVersion ================================================ FILE: test/services/open-webui.nix ================================================ { shb, ... }: let oidcSecret = "oidcSecret"; commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.open-webui.ssl); waitForServices = { ... }: [ "open-webui.service" "nginx.service" ]; waitForPorts = { node, ... }: [ node.config.shb.open-webui.port ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/open-webui.nix ]; test = { subdomain = "o"; }; shb.open-webui = { enable = true; inherit (config.test) subdomain domain; }; # Speeds up tests because models can't be downloaded anyway and that leads to retries. services.open-webui.environment.OFFLINE_MODE = "true"; networking.hosts = { "127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ]; }; }; https = { config, ... }: { shb.open-webui = { ssl = config.shb.certs.certs.selfsigned.n; }; systemd.services.open-webui.environment = { # Needed for open-webui to be able to talk to auth server. SSL_CERT_FILE = "/etc/ssl/certs/ca-certificates.crt"; }; }; ldap = { config, ... }: { shb.open-webui = { ldap = { userGroup = "user_group"; adminGroup = "admin_group"; }; }; }; clientLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; virtualisation.memorySize = 4096; test = { subdomain = "o"; }; test.login = { startUrl = "https://${config.test.fqdn}/auth"; beforeHook = '' page.get_by_role("button", name="continue").click() ''; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('logged in')).to_be_visible()" ]; } { username = "bob"; password = "NotBobPassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "bob"; password = "BobPassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('logged in')).to_be_visible()" ]; } { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "page.get_by_role('button', name=re.compile('Accept')).click()" "expect(page.get_by_text('unauthorized')).to_be_visible()" ]; } ]; }; }; sso = { config, ... }: { shb.open-webui = { sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; clientID = "open-webui"; sharedSecret.result = config.shb.hardcodedsecret.oidcSecret.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.oidcAutheliaSecret.result; }; }; shb.hardcodedsecret.oidcSecret = { request = config.shb.open-webui.sso.sharedSecret.request; settings.content = oidcSecret; }; shb.hardcodedsecret.oidcAutheliaSecret = { request = config.shb.open-webui.sso.sharedSecretForAuthelia.request; settings.content = oidcSecret; }; }; in { basic = shb.test.runNixOSTest { name = "open-webui_basic"; nodes.client = { }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "open-webui_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.open-webui.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "open-webui_https"; nodes.client = { }; nodes.server = { imports = [ basic shb.test.certs https ]; }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "open-webui_sso"; nodes.client = { imports = [ clientLoginSso ]; }; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; testScript = commonTestScript.access; }; } ================================================ FILE: test/services/paperless.nix ================================================ { pkgs, lib, shb, }: let subdomain = "p"; domain = "example.com"; commonTestScript = shb.test.accessScript { hasSSL = { node, ... }: !(isNull node.config.shb.paperless.ssl); waitForServices = { ... }: [ "paperless-web.service" "nginx.service" ]; waitForPorts = { ... }: [ 28981 80 ]; waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ]; }; base = { config, ... }: { imports = [ shb.test.baseModule ../../modules/services/paperless.nix ]; virtualisation.memorySize = 4096; virtualisation.cores = 2; test = { inherit subdomain domain; }; shb.paperless = { enable = true; inherit subdomain domain; }; # Required for tests environment.systemPackages = [ pkgs.curl ]; }; basic = { config, ... }: { imports = [ base ]; test.hasSSL = false; }; https = { config, ... }: { imports = [ base shb.test.certs ]; test.hasSSL = true; shb.paperless.ssl = config.shb.certs.certs.selfsigned.n; }; backup = { config, ... }: { imports = [ https (shb.test.backup config.shb.paperless.backup) ]; }; sso = { config, ... }: { imports = [ https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) ]; shb.paperless.sso = { enable = true; provider = "Authelia"; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; clientID = "paperless"; autoLaunch = true; sharedSecret.result = config.shb.hardcodedsecret.paperlessSSOSecret.result; sharedSecretForAuthelia.result = config.shb.hardcodedsecret.paperlessSSOSecretAuthelia.result; }; shb.hardcodedsecret.paperlessSSOSecret = { request = config.shb.paperless.sso.sharedSecret.request; settings.content = "paperlessSSOSecret"; }; shb.hardcodedsecret.paperlessSSOSecretAuthelia = { request = config.shb.paperless.sso.sharedSecretForAuthelia.request; settings.content = "paperlessSSOSecret"; }; # Configure LDAP groups for group-based access control shb.lldap.ensureGroups.paperless_user = { }; shb.lldap.ensureUsers.paperless_test_user = { email = "paperless_user@example.com"; groups = [ "paperless_user" ]; password.result = config.shb.hardcodedsecret.ldappaperlessUserPassword.result; }; shb.lldap.ensureUsers.regular_test_user = { email = "regular_user@example.com"; groups = [ ]; password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result; }; shb.hardcodedsecret.ldappaperlessUserPassword = { request = config.shb.lldap.ensureUsers.paperless_test_user.password.request; settings.content = "paperless_user_password"; }; shb.hardcodedsecret.ldapRegularUserPassword = { request = config.shb.lldap.ensureUsers.regular_test_user.password.request; settings.content = "regular_user_password"; }; }; in { basic = shb.test.runNixOSTest { name = "paperless-basic"; nodes.server = basic; nodes.client = { }; testScript = commonTestScript; }; https = shb.test.runNixOSTest { name = "paperless-https"; nodes.server = https; nodes.client = { }; testScript = commonTestScript; }; sso = shb.test.runNixOSTest { name = "paperless-https"; nodes.server = sso; nodes.client = { }; testScript = commonTestScript; }; backup = shb.test.runNixOSTest { name = "paperless-backup"; nodes.server = backup; nodes.client = { }; testScript = (shb.test.mkScripts { hasSSL = args: !(isNull args.node.config.shb.paperless.ssl); waitForServices = args: [ "paperless-web.service" "nginx.service" ]; waitForPorts = args: [ 28981 80 ]; waitForUrls = args: [ "${args.proto_fqdn}" ]; }).backup; }; } ================================================ FILE: test/services/pinchflat.nix ================================================ { pkgs, shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.pinchflat.ssl); waitForServices = { ... }: [ "pinchflat.service" "nginx.service" ]; waitForPorts = { node, ... }: [ node.config.shb.pinchflat.port ]; }; basic = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/pinchflat.nix ]; test = { subdomain = "p"; }; shb.pinchflat = { enable = true; inherit (config.test) subdomain domain; mediaDir = "/src/pinchflat"; timeZone = "America/Los_Angeles"; secretKeyBase.result = config.shb.hardcodedsecret.secretKeyBase.result; }; systemd.tmpfiles.rules = [ "d '/src/pinchflat' 0750 pinchflat pinchflat - -" ]; # Needed for gitea-runner-local to be able to ping pinchflat. networking.hosts = { "127.0.0.1" = [ "${config.test.subdomain}.${config.test.domain}" ]; }; shb.hardcodedsecret.secretKeyBase = { request = config.shb.pinchflat.secretKeyBase.request; settings.content = pkgs.lib.strings.replicate 64 "Z"; }; }; clientLogin = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "p"; }; test.login = { startUrl = "http://${config.test.fqdn}"; # There is no login without SSO integration. testLoginWith = [ { username = null; password = null; nextPageExpect = [ "expect(page.get_by_text('Create a media profile')).to_be_visible()" ]; } ]; }; }; https = { config, ... }: { shb.pinchflat = { ssl = config.shb.certs.certs.selfsigned.n; }; }; ldap = { config, ... }: { shb.pinchflat = { ldap = { enable = true; userGroup = "user_group"; }; }; }; clientLoginSso = { config, ... }: { imports = [ shb.test.baseModule shb.test.clientLoginModule ]; test = { subdomain = "p"; }; test.login = { startUrl = "https://${config.test.fqdn}"; usernameFieldLabelRegex = "Username"; passwordFieldLabelRegex = "Password"; loginButtonNameRegex = "[sS]ign [iI]n"; testLoginWith = [ { username = "alice"; password = "NotAlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "alice"; password = "AlicePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).not_to_be_visible()" "expect(page.get_by_role('button', name=re.compile('Sign In'))).not_to_be_visible()" "expect(page.get_by_text('Create a media profile')).to_be_visible()" ]; } # Bob, with its admin role only, cannot login into Karakeep because admins do not exist in Karakeep. { username = "charlie"; password = "NotCharliePassword"; nextPageExpect = [ "expect(page.get_by_text(re.compile('[Ii]ncorrect'))).to_be_visible()" ]; } { username = "charlie"; password = "CharliePassword"; nextPageExpect = [ "expect(page).to_have_url(re.compile('.*/authenticated'))" ]; } ]; }; }; sso = { config, ... }: { shb.pinchflat = { sso = { enable = true; authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; }; in { basic = shb.test.runNixOSTest { name = "pinchflat_basic"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic ]; }; testScript = commonTestScript.access; }; backup = shb.test.runNixOSTest { name = "pinchflat_backup"; nodes.server = { config, ... }: { imports = [ basic (shb.test.backup config.shb.pinchflat.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; https = shb.test.runNixOSTest { name = "pinchflat_https"; nodes.client = { imports = [ clientLogin ]; }; nodes.server = { imports = [ basic shb.test.certs https ]; }; testScript = commonTestScript.access; }; sso = shb.test.runNixOSTest { name = "pinchflat_sso"; nodes.client = { imports = [ clientLoginSso ]; }; nodes.server = { config, pkgs, ... }: { imports = [ basic shb.test.certs https shb.test.ldap ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; testScript = commonTestScript.access.override { redirectSSO = true; }; }; } ================================================ FILE: test/services/vaultwarden.nix ================================================ { shb, ... }: let commonTestScript = shb.test.mkScripts { hasSSL = { node, ... }: !(isNull node.config.shb.vaultwarden.ssl); waitForServices = { ... }: [ "vaultwarden.service" "nginx.service" ]; waitForPorts = { node, ... }: [ 8222 5432 ]; # to get the get token test to succeed we need: # 1. add group Vaultwarden_admin to LLDAP # 2. add an Authelia user with to that group # 3. login in Authelia with that user # 4. go to the Vaultwarden /admin endpoint # 5. create a Vaultwarden user # 6. now login with that new user to Vaultwarden extraScript = { node, proto_fqdn, ... }: '' with subtest("prelogin"): response = curl(client, "", "${proto_fqdn}/identity/accounts/prelogin", data=unline_with("", """ {"email": "me@example.com"} """)) print(response) if 'kdf' not in response: raise Exception("Unrecognized response: {}".format(response)) with subtest("get token"): response = curl(client, "", "${proto_fqdn}/identity/connect/token", data=unline_with("", """ scope=api%20offline_access &client_id=web &deviceType=10 &deviceIdentifier=a60323bf-4686-4b4d-96e0-3c241fa5581c &deviceName=firefox &grant_type=password&username=me &password=mypassword """)) print(response) if response["message"] != "Username or password is incorrect. Try again": raise Exception("Unrecognized response: {}".format(response)) ''; }; basic = { config, ... }: { test = { subdomain = "v"; }; shb.vaultwarden = { enable = true; inherit (config.test) subdomain domain; port = 8222; databasePassword.result = config.shb.hardcodedsecret.passphrase.result; }; shb.hardcodedsecret.passphrase = { request = config.shb.vaultwarden.databasePassword.request; settings.content = "PassPhrase"; }; # networking.hosts = { # "127.0.0.1" = [ fqdn ]; # }; }; https = { config, ... }: { shb.vaultwarden = { ssl = config.shb.certs.certs.selfsigned.n; }; }; # Not yet supported # ldap = { config, ... }: { # # shb.vaultwarden = { # # ldapHostname = "127.0.0.1"; # # ldapPort = config.shb.lldap.webUIListenPort; # # }; # }; sso = { config, ... }: { shb.vaultwarden = { authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; }; }; in { basic = shb.test.runNixOSTest { name = "vaultwarden_basic"; nodes.server = { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/vaultwarden.nix basic ]; }; nodes.client = { }; testScript = commonTestScript.access; }; https = shb.test.runNixOSTest { name = "vaultwarden_https"; nodes.server = { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/vaultwarden.nix shb.test.certs basic https ]; }; nodes.client = { }; testScript = commonTestScript.access; }; # Not yet supported # # ldap = shb.test.runNixOSTest { # name = "vaultwarden_ldap"; # # nodes.server = lib.mkMerge [ # shb.test.baseModule # ../../modules/blocks/hardcodedsecret.nix # ../../modules/services/vaultwarden.nix # basic # ldap # ]; # # nodes.client = {}; # # testScript = commonTestScript.access; # }; sso = shb.test.runNixOSTest { name = "vaultwarden_sso"; nodes.server = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/vaultwarden.nix shb.test.certs basic https shb.test.ldap (shb.test.sso config.shb.certs.certs.selfsigned.n) sso ]; }; nodes.client = { }; testScript = commonTestScript.access.override { waitForPorts = { node, ... }: [ 8222 5432 9091 ]; extraScript = { node, proto_fqdn, ... }: '' with subtest("unauthenticated access is not granted to /admin"): response = curl(client, """{"code":%{response_code},"auth_host":"%{urle.host}","auth_query":"%{urle.query}","all":%{json}}""", "${proto_fqdn}/admin") if response['code'] != 200: raise Exception(f"Code is {response['code']}") if response['auth_host'] != "auth.${node.config.test.domain}": raise Exception(f"auth host should be auth.${node.config.test.domain} but is {response['auth_host']}") if response['auth_query'] != "rd=${proto_fqdn}/admin": raise Exception(f"auth query should be rd=${proto_fqdn}/admin but is {response['auth_query']}") ''; }; }; backup = shb.test.runNixOSTest { name = "vaultwarden_backup"; nodes.server = { config, ... }: { imports = [ shb.test.baseModule ../../modules/blocks/hardcodedsecret.nix ../../modules/services/vaultwarden.nix basic (shb.test.backup config.shb.vaultwarden.backup) ]; }; nodes.client = { }; testScript = commonTestScript.backup; }; }